fix(coding-agent): fixed python kernel startup hangs (#548)
* fix(coding-agent): fixed python kernel startup hangs * fix(coding-agent): fixed startup timeout regressions * fix(coding-agent): preserved startup cancellation typing
This commit is contained in:
@@ -186,6 +186,138 @@ describe("PythonKernel gateway lifecycle", () => {
|
||||
expect(kernel.isAlive()).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts stalled startup after websocket connect and cleans up the kernel", async () => {
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
url: "http://127.0.0.1:9999",
|
||||
isShared: true,
|
||||
});
|
||||
|
||||
let executeCallCount = 0;
|
||||
const preludeStarted = Promise.withResolvers<void>();
|
||||
vi.spyOn(PythonKernel.prototype, "execute").mockImplementation(async (_code, options) => {
|
||||
executeCallCount += 1;
|
||||
if (executeCallCount === 1) {
|
||||
return { status: "ok", cancelled: false, timedOut: false, stdinRequested: false };
|
||||
}
|
||||
preludeStarted.resolve();
|
||||
return await new Promise((_, reject) => {
|
||||
const onAbort = () => {
|
||||
const reason = options?.signal?.reason;
|
||||
reject(reason instanceof Error ? reason : new Error("Python kernel startup aborted"));
|
||||
};
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
using _hook = hookFetch((input, init) => {
|
||||
const url = String(input);
|
||||
env.fetchCalls.push({ url, init });
|
||||
if (url.endsWith("/api/kernels") && init?.method === "POST") {
|
||||
return createResponse({ ok: true, json: { id: "kernel-stalled" } }) as unknown as Response;
|
||||
}
|
||||
return createResponse({ ok: true }) as unknown as Response;
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const startPromise = PythonKernel.start({ cwd: tempDir.path(), signal: abortController.signal });
|
||||
await preludeStarted.promise;
|
||||
abortController.abort(new Error("cancel startup"));
|
||||
|
||||
const pending = Symbol("pending");
|
||||
const settled = await Promise.race([
|
||||
startPromise.then(
|
||||
() => "resolved",
|
||||
error => error,
|
||||
),
|
||||
Bun.sleep(50).then(() => pending),
|
||||
]);
|
||||
|
||||
expect(settled).toBeInstanceOf(Error);
|
||||
expect(settled).not.toBe(pending);
|
||||
expect((settled as Error).message).toContain("cancel startup");
|
||||
await expect(startPromise).rejects.toThrow("cancel startup");
|
||||
expect(
|
||||
env.fetchCalls.some(
|
||||
call => call.url.endsWith("/api/kernels/kernel-stalled") && call.init?.method === "DELETE",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
});
|
||||
|
||||
it("preserves timeout classification when startup environment initialization is cancelled", async () => {
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
url: "http://127.0.0.1:9999",
|
||||
isShared: true,
|
||||
});
|
||||
|
||||
vi.spyOn(PythonKernel.prototype, "execute").mockResolvedValue({
|
||||
status: "ok",
|
||||
cancelled: true,
|
||||
timedOut: true,
|
||||
stdinRequested: false,
|
||||
});
|
||||
|
||||
using _hook = hookFetch((input, init) => {
|
||||
const url = String(input);
|
||||
env.fetchCalls.push({ url, init });
|
||||
if (url.endsWith("/api/kernels") && init?.method === "POST") {
|
||||
return createResponse({ ok: true, json: { id: "kernel-init-timeout" } }) as unknown as Response;
|
||||
}
|
||||
return createResponse({ ok: true }) as unknown as Response;
|
||||
});
|
||||
|
||||
await expect(PythonKernel.start({ cwd: tempDir.path() })).rejects.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "Failed to initialize Python kernel environment",
|
||||
});
|
||||
expect(
|
||||
env.fetchCalls.some(
|
||||
call => call.url.endsWith("/api/kernels/kernel-init-timeout") && call.init?.method === "DELETE",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
});
|
||||
|
||||
it("preserves timeout classification when startup prelude execution is cancelled", async () => {
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
url: "http://127.0.0.1:9999",
|
||||
isShared: true,
|
||||
});
|
||||
|
||||
let executeCallCount = 0;
|
||||
vi.spyOn(PythonKernel.prototype, "execute").mockImplementation(async () => {
|
||||
executeCallCount += 1;
|
||||
if (executeCallCount === 1) {
|
||||
return { status: "ok", cancelled: false, timedOut: false, stdinRequested: false };
|
||||
}
|
||||
return { status: "ok", cancelled: true, timedOut: true, stdinRequested: false };
|
||||
});
|
||||
|
||||
using _hook = hookFetch((input, init) => {
|
||||
const url = String(input);
|
||||
env.fetchCalls.push({ url, init });
|
||||
if (url.endsWith("/api/kernels") && init?.method === "POST") {
|
||||
return createResponse({ ok: true, json: { id: "kernel-prelude-timeout" } }) as unknown as Response;
|
||||
}
|
||||
return createResponse({ ok: true }) as unknown as Response;
|
||||
});
|
||||
|
||||
await expect(PythonKernel.start({ cwd: tempDir.path() })).rejects.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "Failed to initialize Python kernel prelude",
|
||||
});
|
||||
expect(
|
||||
env.fetchCalls.some(
|
||||
call => call.url.endsWith("/api/kernels/kernel-prelude-timeout") && call.init?.method === "DELETE",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
});
|
||||
|
||||
it("throws when shared gateway kernel creation never succeeds", async () => {
|
||||
using _runtime = stubKernelRuntime();
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user