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.
|
||||
- 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.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user