From 2e34f01adf43d47eb186cadf8812f94461941736 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 31 Jul 2026 18:07:32 -0700 Subject: [PATCH] fix(anime): honor the sidecar readiness deadline and confirm shutdown The capabilities probe used a fixed 5s timeout, so a readiness budget shorter than that could be overrun by one stalled request. Pass the remaining deadline down instead. stop() also resolved after the SIGKILL wait even when the child had not exited, letting a restart race a process still holding the port. Throw in that case, and keep a failed shutdown from masking the readiness error at startup. --- src/anime-bridge/bridge-client.test.ts | 14 ++++++++++++++ src/anime-bridge/bridge-client.ts | 12 ++++++++---- src/anime-bridge/sidecar-process.test.ts | 23 +++++++++++++++++++++-- src/anime-bridge/sidecar-process.ts | 12 ++++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/anime-bridge/bridge-client.test.ts b/src/anime-bridge/bridge-client.test.ts index eb170648..9f0da069 100644 --- a/src/anime-bridge/bridge-client.test.ts +++ b/src/anime-bridge/bridge-client.test.ts @@ -55,6 +55,20 @@ test('isReady requires every capability the client depends on', async () => { assert.equal(await partial.isReady(), false); }); +test('isReady caps the probe at the deadline the caller passes', async () => { + // A bridge that accepts the socket and then stalls: only the abort ends it. + const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => { + await new Promise((resolve) => init?.signal?.addEventListener('abort', resolve)); + throw new Error('aborted'); + }) as typeof fetch; + const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl }); + + const started = Date.now(); + assert.equal(await client.isReady(50), false); + // Well under the 5s default, so the per-call deadline is what applied. + assert.ok(Date.now() - started < 1000, 'probe outlived the caller deadline'); +}); + test('isReady reports false instead of throwing when the bridge is down', async () => { const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', diff --git a/src/anime-bridge/bridge-client.ts b/src/anime-bridge/bridge-client.ts index 2a20a076..c8ba6e20 100644 --- a/src/anime-bridge/bridge-client.ts +++ b/src/anime-bridge/bridge-client.ts @@ -74,9 +74,13 @@ export class AnimeBridgeClient { this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; } - async getCapabilities(): Promise { + /** + * `timeoutMs` lets a caller with its own deadline (the readiness loop) cap the + * probe below the default, so a short readiness budget is actually honored. + */ + async getCapabilities(timeoutMs = CAPABILITIES_TIMEOUT_MS): Promise { const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, { - signal: AbortSignal.timeout(Math.min(CAPABILITIES_TIMEOUT_MS, this.requestTimeoutMs)), + signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, this.requestTimeoutMs))), }); if (!response.ok) { throw new Error(`Anime bridge capabilities check failed (${response.status}).`); @@ -85,9 +89,9 @@ export class AnimeBridgeClient { } /** True once the bridge is up and reports the features this client needs. */ - async isReady(): Promise { + async isReady(timeoutMs?: number): Promise { try { - const capabilities = await this.getCapabilities(); + const capabilities = await this.getCapabilities(timeoutMs); return ( capabilities.mangatanMihonBridge === 1 && capabilities.sourceFactory === true && diff --git a/src/anime-bridge/sidecar-process.test.ts b/src/anime-bridge/sidecar-process.test.ts index 0d9ac72b..d9b292b7 100644 --- a/src/anime-bridge/sidecar-process.test.ts +++ b/src/anime-bridge/sidecar-process.test.ts @@ -11,9 +11,16 @@ const binaries: BundleBinaries = { }; /** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */ -function fakeChild(): ChildProcess { +function fakeChild(onKill?: (child: EventEmitter) => void): ChildProcess { const child = new EventEmitter(); - Object.assign(child, { stdout: null, stderr: null, kill: () => true }); + Object.assign(child, { + stdout: null, + stderr: null, + kill: () => { + onKill?.(child); + return true; + }, + }); return child as unknown as ChildProcess; } @@ -32,6 +39,18 @@ test('a failed spawn rejects instead of throwing an unhandled error event', asyn ); }); +test('a readiness timeout shuts the child down and reports the timeout', async () => { + const port = await allocatePort(); + // Never becomes ready, but does go down on the first signal. + const child = fakeChild((emitter) => queueMicrotask(() => emitter.emit('exit', 0, 'SIGTERM'))); + const spawnImpl = (() => child) as unknown as typeof spawnType; + + await assert.rejects( + () => startSidecar({ binaries, port, readyTimeoutMs: 50, spawnImpl }), + /did not become ready within 50ms/, + ); +}); + test('an early exit is reported with its code rather than waiting out the deadline', async () => { const port = await allocatePort(); const child = fakeChild(); diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts index 4675b410..3423406a 100644 --- a/src/anime-bridge/sidecar-process.ts +++ b/src/anime-bridge/sidecar-process.ts @@ -107,6 +107,11 @@ export async function startSidecar(options: StartSidecarOptions): Promise {}); throw new Error( `Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`, );