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:
2026-08-01 01:05:40 -07:00
parent 1c50ff74cb
commit d7188a2155
4 changed files with 94 additions and 11 deletions
+1
View File
@@ -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.
- 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.
- 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.
- 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.
+39
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import type { spawn as spawnType, ChildProcess } from 'node:child_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/,
);
});
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()));
}
});
+14 -1
View File
@@ -35,6 +35,14 @@ export async function allocatePort(): Promise<number> {
export interface SidecarHandle {
baseUrl: string;
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;
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
// 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);
}
+40 -10
View File
@@ -129,8 +129,17 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
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 };
}
@@ -146,6 +155,27 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
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 {
stripProxy = await startStreamStripProxy({
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
@@ -256,7 +286,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
page: number,
) => Promise<BridgeAnimePage>,
): Promise<AnimeBrowserSearchResult> {
const { baseUrl } = requireBridge();
const { baseUrl } = await bridge();
const targets =
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
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.
*/
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
const { client } = requireBridge();
const { client } = await bridge();
const source = requireSource(sourceId);
const schema = await client.getSourcePreferences(await sourceFor(source.id));
return parsePreferences(schema);
@@ -417,7 +447,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
key: string,
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> {
const { client } = requireBridge();
const { client } = await bridge();
const source = await sourceFor(sourceId);
// 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> {
const { client } = requireBridge();
const { client } = await bridge();
return browse(page, (source, requestedPage) =>
client.searchAnime(source, query, requestedPage),
);
},
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
const { client } = requireBridge();
const { client } = await bridge();
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
},
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
const { client, baseUrl } = requireBridge();
const { client, baseUrl } = await bridge();
const source = requireSource(sourceId ?? selectedSourceId);
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
return {
@@ -461,7 +491,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
const { client } = requireBridge();
const { client } = await bridge();
const source = requireSource(sourceId ?? selectedSourceId);
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
return episodes.map((episode) => ({
@@ -478,7 +508,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
try {
const { client, baseUrl } = requireBridge();
const { client, baseUrl } = await bridge();
const videos = await client.getVideoList(
await sourceFor(request.sourceId),
request.episodeUrl,