diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 15904034..66130f06 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -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. diff --git a/src/anime-bridge/sidecar-process.test.ts b/src/anime-bridge/sidecar-process.test.ts index d9b292b7..1525e715 100644 --- a/src/anime-bridge/sidecar-process.test.ts +++ b/src/anime-bridge/sidecar-process.test.ts @@ -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((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((resolve) => server.close(() => resolve())); + } +}); diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts index 3423406a..47a92760 100644 --- a/src/anime-bridge/sidecar-process.ts +++ b/src/anime-bridge/sidecar-process.ts @@ -35,6 +35,14 @@ export async function allocatePort(): Promise { 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; } @@ -129,7 +137,12 @@ export async function startSidecar(options: StartSidecarOptions): Promise { + void hasExited.then(() => listener(exited ?? { code: null, signal: null })); + }; + return { baseUrl, port, client, stop, onExit }; + } await delay(READY_POLL_INTERVAL_MS); } diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index 4a79a3c9..a8ff7cab 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -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, ): Promise { - 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 { - 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 { - 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 { - const { client } = requireBridge(); + const { client } = await bridge(); return browse(page, (source, requestedPage) => client.searchAnime(source, query, requestedPage), ); }, async getPopular(page = 1): Promise { - const { client } = requireBridge(); + const { client } = await bridge(); return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage)); }, async getDetails(animeUrl: string, sourceId?: string): Promise { - 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 { - 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 { try { - const { client, baseUrl } = requireBridge(); + const { client, baseUrl } = await bridge(); const videos = await client.getVideoList( await sourceFor(request.sourceId), request.episodeUrl,