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
+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);
}