mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
fix(anime): report a failed bridge kill instead of a generic timeout
- Track kill errors separately from exit/spawn so a signal failure (e.g. EPERM) surfaces its own message instead of a bare "did not exit after SIGKILL" - Attach stop() failures as the cause on the readiness-timeout error so a surviving child's kill failure isn't dropped - Skip stop() entirely when spawn itself failed, and stop polling once the ready deadline has passed - Add stopTimeoutMs test hook and a test covering a kill that fails without the child exiting
This commit is contained in:
@@ -11,12 +11,17 @@ const binaries: BundleBinaries = {
|
|||||||
jarPath: '/tmp/MExtensionServer.jar',
|
jarPath: '/tmp/MExtensionServer.jar',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */
|
/**
|
||||||
function fakeChild(onKill?: (child: EventEmitter) => void): ChildProcess {
|
* A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches.
|
||||||
|
* A `pid` marks a child that did spawn, which is how a kill failure is told
|
||||||
|
* apart from a spawn failure.
|
||||||
|
*/
|
||||||
|
function fakeChild(onKill?: (child: EventEmitter) => void, pid?: number): ChildProcess {
|
||||||
const child = new EventEmitter();
|
const child = new EventEmitter();
|
||||||
Object.assign(child, {
|
Object.assign(child, {
|
||||||
stdout: null,
|
stdout: null,
|
||||||
stderr: null,
|
stderr: null,
|
||||||
|
pid,
|
||||||
kill: () => {
|
kill: () => {
|
||||||
onKill?.(child);
|
onKill?.(child);
|
||||||
return true;
|
return true;
|
||||||
@@ -52,6 +57,26 @@ test('a readiness timeout shuts the child down and reports the timeout', async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a kill that fails without an exit is reported, not counted as a shutdown', async () => {
|
||||||
|
const port = await allocatePort();
|
||||||
|
// Signalling fails (EPERM-style) and the child never goes: it may still hold
|
||||||
|
// the port, so the stop must not report success.
|
||||||
|
const child = fakeChild(
|
||||||
|
(emitter) => queueMicrotask(() => emitter.emit('error', new Error('kill EPERM'))),
|
||||||
|
4242,
|
||||||
|
);
|
||||||
|
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => startSidecar({ binaries, port, readyTimeoutMs: 50, stopTimeoutMs: 10, spawnImpl }),
|
||||||
|
(error: Error) => {
|
||||||
|
assert.match(error.message, /did not become ready within 50ms/);
|
||||||
|
assert.match((error.cause as Error).message, /could not be killed.*EPERM/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
|
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
|
||||||
const port = await allocatePort();
|
const port = await allocatePort();
|
||||||
const child = fakeChild();
|
const child = fakeChild();
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface StartSidecarOptions {
|
|||||||
binaries: BundleBinaries;
|
binaries: BundleBinaries;
|
||||||
port?: number;
|
port?: number;
|
||||||
readyTimeoutMs?: number;
|
readyTimeoutMs?: number;
|
||||||
|
/** How long to wait for the child to go after each stop signal. Tests only. */
|
||||||
|
stopTimeoutMs?: number;
|
||||||
spawnImpl?: typeof spawn;
|
spawnImpl?: typeof spawn;
|
||||||
onLog?: (line: string) => void;
|
onLog?: (line: string) => void;
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,8 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
|
|
||||||
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||||
let spawnError: Error | null = null;
|
let spawnError: Error | null = null;
|
||||||
|
let killError: Error | null = null;
|
||||||
|
const stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
|
||||||
const hasExited = new Promise<void>((resolve) => {
|
const hasExited = new Promise<void>((resolve) => {
|
||||||
child.once('exit', (code, signal) => {
|
child.once('exit', (code, signal) => {
|
||||||
exited = { code, signal };
|
exited = { code, signal };
|
||||||
@@ -90,16 +94,24 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
});
|
});
|
||||||
// A ChildProcess is an EventEmitter: without this listener a failed spawn
|
// A ChildProcess is an EventEmitter: without this listener a failed spawn
|
||||||
// (a missing or non-executable java) throws in the main process instead of
|
// (a missing or non-executable java) throws in the main process instead of
|
||||||
// failing the readiness loop below.
|
// failing the readiness loop below. `error` never proves the child is gone
|
||||||
child.once('error', (error: Error) => {
|
// -- only `exit` does -- so it must never set `exited`, or a kill that
|
||||||
spawnError = error;
|
// failed would look like a clean shutdown. A spawn failure is told apart
|
||||||
if (exited === null) exited = { code: null, signal: null };
|
// from a later kill failure by the missing pid.
|
||||||
resolve();
|
child.on('error', (error: Error) => {
|
||||||
|
if (child.pid === undefined) {
|
||||||
|
spawnError = error;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
killError = error;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const stop = async (): Promise<void> => {
|
const stop = async (): Promise<void> => {
|
||||||
if (exited !== null) return;
|
if (exited !== null) return;
|
||||||
|
// Nothing was ever spawned, so there is nothing to signal or wait for.
|
||||||
|
if (spawnError !== null) return;
|
||||||
// Graceful first: the server exposes a shutdown endpoint.
|
// Graceful first: the server exposes a shutdown endpoint.
|
||||||
try {
|
try {
|
||||||
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
|
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
|
||||||
@@ -110,14 +122,20 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
child.kill();
|
child.kill();
|
||||||
// kill() only sends the signal. Wait for the process to actually go, so a
|
// kill() only sends the signal. Wait for the process to actually go, so a
|
||||||
// restart cannot race the old one still holding the port.
|
// restart cannot race the old one still holding the port.
|
||||||
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
|
await Promise.race([hasExited, delay(stopTimeoutMs)]);
|
||||||
if (exited === null) {
|
if (exited === null) {
|
||||||
child.kill('SIGKILL');
|
child.kill('SIGKILL');
|
||||||
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
|
await Promise.race([hasExited, delay(stopTimeoutMs)]);
|
||||||
}
|
}
|
||||||
// Never report success while the child may still hold the port: a caller
|
// Never report success while the child may still hold the port: a caller
|
||||||
// that restarts on the same port would race the survivor.
|
// that restarts on the same port would race the survivor.
|
||||||
if (exited === null) {
|
if (exited === null) {
|
||||||
|
const failedKill = killError as Error | null;
|
||||||
|
if (failedKill !== null) {
|
||||||
|
throw new Error(`Anime bridge could not be killed: ${failedKill.message}`, {
|
||||||
|
cause: failedKill,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error('Anime bridge did not exit after SIGKILL.');
|
throw new Error('Anime bridge did not exit after SIGKILL.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -143,12 +161,20 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
};
|
};
|
||||||
return { baseUrl, port, client, stop, onExit };
|
return { baseUrl, port, client, stop, onExit };
|
||||||
}
|
}
|
||||||
await delay(READY_POLL_INTERVAL_MS);
|
// Sleeping past the deadline would only delay the timeout report.
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await delay(Math.min(READY_POLL_INTERVAL_MS, remaining));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failed shutdown must not mask why startup failed.
|
const timeout = new Error(
|
||||||
await stop().catch(() => {});
|
|
||||||
throw new Error(
|
|
||||||
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
|
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
|
||||||
);
|
);
|
||||||
|
// A failed shutdown must not mask why startup failed, but it must not be
|
||||||
|
// dropped either: a surviving child still holds the port, which decides
|
||||||
|
// whether a caller may retry on it.
|
||||||
|
await stop().catch((error: unknown) => {
|
||||||
|
timeout.cause = error;
|
||||||
|
});
|
||||||
|
throw timeout;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user