mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
fix(anime): restart the bridge on the next request after it dies
The reported 'APK crash' turned out not to exist: repeated full scans of all installed extensions load cleanly. The real incident was the app's quit sequence stopping the bridge (its /stop endpoint) while requests were in flight - and it exposed that a bridge dying for any reason left the anime browser failing every call with a raw 'fetch failed' until the whole app was restarted. SidecarHandle now reports the child's exit (including to late subscribers), and the runtime detects a death it did not order: it drops the dead handle, closes the strip proxy, tells the UI, and the next request transparently starts a fresh bridge. requireBridge became an async bridge() that starts on demand, which also covers first use. Verified live: search -> SIGKILL the JVM -> search recovers.
This commit is contained in:
@@ -16,6 +16,7 @@ area: anime
|
|||||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
|
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
|
||||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
|
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
|
||||||
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
||||||
|
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
||||||
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
||||||
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
|
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
|
||||||
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
|
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
|
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
|
||||||
import { allocatePort, startSidecar } from './sidecar-process';
|
import { allocatePort, startSidecar } from './sidecar-process';
|
||||||
@@ -64,3 +65,41 @@ test('an early exit is reported with its code rather than waiting out the deadli
|
|||||||
/exited before becoming ready \(code 1/,
|
/exited before becoming ready \(code 1/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('onExit reports a death after readiness, including to late subscribers', async () => {
|
||||||
|
const port = await allocatePort();
|
||||||
|
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
|
||||||
|
const server = http.createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve));
|
||||||
|
const child = fakeChild();
|
||||||
|
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await startSidecar({ binaries, port, readyTimeoutMs: 5000, spawnImpl });
|
||||||
|
const exits: Array<{ code: number | null; signal: NodeJS.Signals | null }> = [];
|
||||||
|
handle.onExit((info) => exits.push(info));
|
||||||
|
assert.equal(exits.length, 0);
|
||||||
|
|
||||||
|
child.emit('exit', 137, null);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.deepEqual([...exits], [{ code: 137, signal: null }]);
|
||||||
|
|
||||||
|
// A listener attached after the death still hears about it.
|
||||||
|
handle.onExit((info) => exits.push(info));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.deepEqual(
|
||||||
|
[...exits],
|
||||||
|
[
|
||||||
|
{ code: 137, signal: null },
|
||||||
|
{ code: 137, signal: null },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export async function allocatePort(): Promise<number> {
|
|||||||
export interface SidecarHandle {
|
export interface SidecarHandle {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
/**
|
||||||
|
* Fires once when the child process goes away, however it goes away —
|
||||||
|
* including deliberate stops. Fires immediately for a subscriber that
|
||||||
|
* attaches after the death, so a caller cannot miss it.
|
||||||
|
*/
|
||||||
|
onExit: (
|
||||||
|
listener: (info: { code: number | null; signal: NodeJS.Signals | null }) => void,
|
||||||
|
) => void;
|
||||||
client: AnimeBridgeClient;
|
client: AnimeBridgeClient;
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -129,7 +137,12 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
}
|
}
|
||||||
// Cap the probe at the time actually left, so a short readiness budget is
|
// Cap the probe at the time actually left, so a short readiness budget is
|
||||||
// not overrun by a single stalled capabilities request.
|
// not overrun by a single stalled capabilities request.
|
||||||
if (await client.isReady(deadline - Date.now())) return { baseUrl, port, client, stop };
|
if (await client.isReady(deadline - Date.now())) {
|
||||||
|
const onExit: SidecarHandle['onExit'] = (listener) => {
|
||||||
|
void hasExited.then(() => listener(exited ?? { code: null, signal: null }));
|
||||||
|
};
|
||||||
|
return { baseUrl, port, client, stop, onExit };
|
||||||
|
}
|
||||||
await delay(READY_POLL_INTERVAL_MS);
|
await delay(READY_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,8 +129,17 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
return { ...toBridgeSource(extension, source.id), preferences: saved };
|
return { ...toBridgeSource(extension, source.id), preferences: saved };
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireBridge(): { client: AnimeBridgeClient; baseUrl: string } {
|
/**
|
||||||
if (!sidecar) throw new Error('The anime bridge is not running yet.');
|
* The live bridge, starting (or restarting) it first when there is none.
|
||||||
|
* A bridge that died out from under the app — killed, crashed, stopped —
|
||||||
|
* comes back on the next request instead of failing every call until the
|
||||||
|
* app restarts.
|
||||||
|
*/
|
||||||
|
async function bridge(): Promise<{ client: AnimeBridgeClient; baseUrl: string }> {
|
||||||
|
if (!sidecar) await ensureBridge();
|
||||||
|
if (!sidecar) {
|
||||||
|
throw new Error(bridgeState.message ?? 'The anime bridge is not running.');
|
||||||
|
}
|
||||||
return { client: sidecar.client, baseUrl: sidecar.baseUrl };
|
return { client: sidecar.client, baseUrl: sidecar.baseUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +155,27 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
});
|
});
|
||||||
sidecar = handle;
|
sidecar = handle;
|
||||||
|
|
||||||
|
// A deliberate stop detaches first (dispose nulls `sidecar` before
|
||||||
|
// stopping, a restart replaces it), so reaching the body means the bridge
|
||||||
|
// died out from under us and the next request should bring it back.
|
||||||
|
handle.onExit(({ code, signal }) => {
|
||||||
|
if (sidecar !== handle) return;
|
||||||
|
sidecar = null;
|
||||||
|
starting = null;
|
||||||
|
const proxy = stripProxy;
|
||||||
|
stripProxy = null;
|
||||||
|
void proxy?.close();
|
||||||
|
deps.log(
|
||||||
|
`[anime-browser] bridge exited unexpectedly (code ${code}, signal ${signal}); ` +
|
||||||
|
'it will restart on the next request',
|
||||||
|
);
|
||||||
|
setState({
|
||||||
|
stage: 'idle',
|
||||||
|
progress: null,
|
||||||
|
message: 'The anime bridge stopped. It restarts on the next search.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stripProxy = await startStreamStripProxy({
|
stripProxy = await startStreamStripProxy({
|
||||||
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
|
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
|
||||||
@@ -256,7 +286,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
page: number,
|
page: number,
|
||||||
) => Promise<BridgeAnimePage>,
|
) => Promise<BridgeAnimePage>,
|
||||||
): Promise<AnimeBrowserSearchResult> {
|
): Promise<AnimeBrowserSearchResult> {
|
||||||
const { baseUrl } = requireBridge();
|
const { baseUrl } = await bridge();
|
||||||
const targets =
|
const targets =
|
||||||
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
|
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
|
||||||
if (targets.length === 0) throw new Error('No sources are installed.');
|
if (targets.length === 0) throw new Error('No sources are installed.');
|
||||||
@@ -402,7 +432,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
* what it has no memory of between requests.
|
* what it has no memory of between requests.
|
||||||
*/
|
*/
|
||||||
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
|
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
|
||||||
const { client } = requireBridge();
|
const { client } = await bridge();
|
||||||
const source = requireSource(sourceId);
|
const source = requireSource(sourceId);
|
||||||
const schema = await client.getSourcePreferences(await sourceFor(source.id));
|
const schema = await client.getSourcePreferences(await sourceFor(source.id));
|
||||||
return parsePreferences(schema);
|
return parsePreferences(schema);
|
||||||
@@ -417,7 +447,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
key: string,
|
key: string,
|
||||||
value: string | string[] | boolean,
|
value: string | string[] | boolean,
|
||||||
): Promise<SourcePreferenceView[]> {
|
): Promise<SourcePreferenceView[]> {
|
||||||
const { client } = requireBridge();
|
const { client } = await bridge();
|
||||||
const source = await sourceFor(sourceId);
|
const source = await sourceFor(sourceId);
|
||||||
|
|
||||||
// Start from the extension's own schema so saved values never go stale
|
// Start from the extension's own schema so saved values never go stale
|
||||||
@@ -436,19 +466,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
|
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
|
||||||
const { client } = requireBridge();
|
const { client } = await bridge();
|
||||||
return browse(page, (source, requestedPage) =>
|
return browse(page, (source, requestedPage) =>
|
||||||
client.searchAnime(source, query, requestedPage),
|
client.searchAnime(source, query, requestedPage),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
|
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
|
||||||
const { client } = requireBridge();
|
const { client } = await bridge();
|
||||||
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
|
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
|
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
|
||||||
const { client, baseUrl } = requireBridge();
|
const { client, baseUrl } = await bridge();
|
||||||
const source = requireSource(sourceId ?? selectedSourceId);
|
const source = requireSource(sourceId ?? selectedSourceId);
|
||||||
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
|
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
|
||||||
return {
|
return {
|
||||||
@@ -461,7 +491,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
|
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
|
||||||
const { client } = requireBridge();
|
const { client } = await bridge();
|
||||||
const source = requireSource(sourceId ?? selectedSourceId);
|
const source = requireSource(sourceId ?? selectedSourceId);
|
||||||
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
|
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
|
||||||
return episodes.map((episode) => ({
|
return episodes.map((episode) => ({
|
||||||
@@ -478,7 +508,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
|
|
||||||
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||||
try {
|
try {
|
||||||
const { client, baseUrl } = requireBridge();
|
const { client, baseUrl } = await bridge();
|
||||||
const videos = await client.getVideoList(
|
const videos = await client.getVideoList(
|
||||||
await sourceFor(request.sourceId),
|
await sourceFor(request.sourceId),
|
||||||
request.episodeUrl,
|
request.episodeUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user