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 a34c3e95c5
commit b528440d48
4 changed files with 94 additions and 11 deletions
+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,