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.
This commit is contained in:
2026-07-31 18:07:32 -07:00
parent f5e98dfa9d
commit 2e34f01adf
4 changed files with 53 additions and 8 deletions
+14
View File
@@ -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',
+8 -4
View File
@@ -74,9 +74,13 @@ export class AnimeBridgeClient {
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
async getCapabilities(): Promise<BridgeCapabilities> {
/**
* `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<BridgeCapabilities> {
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<boolean> {
async isReady(timeoutMs?: number): Promise<boolean> {
try {
const capabilities = await this.getCapabilities();
const capabilities = await this.getCapabilities(timeoutMs);
return (
capabilities.mangatanMihonBridge === 1 &&
capabilities.sourceFactory === true &&
+21 -2
View File
@@ -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();
+10 -2
View File
@@ -107,6 +107,11 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
child.kill('SIGKILL');
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
}
// Never report success while the child may still hold the port: a caller
// that restarts on the same port would race the survivor.
if (exited === null) {
throw new Error('Anime bridge did not exit after SIGKILL.');
}
};
const client = new AnimeBridgeClient({ baseUrl });
@@ -122,11 +127,14 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
);
}
if (await client.isReady()) return { baseUrl, port, client, stop };
// 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 };
await delay(READY_POLL_INTERVAL_MS);
}
await stop();
// A failed shutdown must not mask why startup failed.
await stop().catch(() => {});
throw new Error(
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
);