fix(coding-agent): scoped python kernel cleanup to sessions
scoped retained-kernel ownership to agent sessions and cleaned it up on session disposal cleaned up warmed python owners on session startup failure and rejected new direct and tool-based python starts during disposal, including async hook, preflight, and warmup races
This commit is contained in:
@@ -339,6 +339,97 @@ describe("PythonKernel gateway lifecycle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a retry against an already-missing kernel as confirmed shutdown", async () => {
|
||||
using _runtime = stubKernelRuntime();
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
url: "http://127.0.0.1:9999",
|
||||
isShared: true,
|
||||
});
|
||||
|
||||
let deleteCalls = 0;
|
||||
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-retry-delete" } }) as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/api/kernels/kernel-retry-delete") && init?.method === "DELETE") {
|
||||
deleteCalls += 1;
|
||||
if (deleteCalls === 1) {
|
||||
return createResponse({ ok: false, status: 503, text: "not yet" }) as unknown as Response;
|
||||
}
|
||||
return createResponse({ ok: false, status: 404, text: "gone" }) as unknown as Response;
|
||||
}
|
||||
return createResponse({ ok: true }) as unknown as Response;
|
||||
});
|
||||
|
||||
const kernel = await PythonKernel.start({ cwd: tempDir.path() });
|
||||
|
||||
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: false });
|
||||
expect(kernel.isAlive()).toBe(false);
|
||||
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
|
||||
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: true });
|
||||
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: true });
|
||||
expect(deleteCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("returns unconfirmed when shutdown times out and can confirm on retry", async () => {
|
||||
using _runtime = stubKernelRuntime();
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
url: "http://127.0.0.1:9999",
|
||||
isShared: true,
|
||||
});
|
||||
let deleteCalls = 0;
|
||||
const firstDeleteStarted = Promise.withResolvers<void>();
|
||||
const firstDeleteAborted = Promise.withResolvers<void>();
|
||||
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-shutdown-timeout" } }) as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/api/kernels/kernel-shutdown-timeout") && init?.method === "DELETE") {
|
||||
deleteCalls += 1;
|
||||
if (deleteCalls === 1) {
|
||||
firstDeleteStarted.resolve();
|
||||
return new Promise<Response>((_, reject) => {
|
||||
const waitForAbort = () => {
|
||||
if (init.signal?.aborted) {
|
||||
firstDeleteAborted.resolve();
|
||||
const reason = init.signal.reason;
|
||||
reject(reason instanceof Error ? reason : new Error("Python kernel shutdown timed out"));
|
||||
return;
|
||||
}
|
||||
const poll = setTimeout(waitForAbort, 5);
|
||||
poll.unref?.();
|
||||
};
|
||||
waitForAbort();
|
||||
});
|
||||
}
|
||||
return createResponse({ ok: false, status: 404, text: "gone" }) as unknown as Response;
|
||||
}
|
||||
return createResponse({ ok: true }) as unknown as Response;
|
||||
});
|
||||
const kernel = await PythonKernel.start({ cwd: tempDir.path() });
|
||||
const shutdownPromise = kernel.shutdown({ timeoutMs: 25 });
|
||||
await firstDeleteStarted.promise;
|
||||
const pending = Symbol("pending");
|
||||
const settled = await Promise.race([
|
||||
shutdownPromise,
|
||||
new Promise<typeof pending>(resolve => {
|
||||
const timer = setTimeout(() => resolve(pending), 250);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
await firstDeleteAborted.promise;
|
||||
expect(settled).not.toBe(pending);
|
||||
expect(settled).toEqual({ confirmed: false });
|
||||
expect(kernel.isAlive()).toBe(false);
|
||||
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: true });
|
||||
expect(deleteCalls).toBe(2);
|
||||
});
|
||||
it("does not throw when shutdown API fails", async () => {
|
||||
using _runtime = stubKernelRuntime();
|
||||
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
|
||||
@@ -360,6 +451,6 @@ describe("PythonKernel gateway lifecycle", () => {
|
||||
|
||||
const kernel = await PythonKernel.start({ cwd: tempDir.path() });
|
||||
|
||||
await expect(kernel.shutdown()).resolves.toBeUndefined();
|
||||
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: false });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user