mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
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:
@@ -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',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user