From 6d5d0a367314d6b997111619dbcca9dac62fce97 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 30 Apr 2026 18:45:55 +0800 Subject: [PATCH] fix: do not count normal dispatcher exits as crashes The close handler called recordCrash() unconditionally, even for exit code 0 (normal MAX_REQUESTS restart). After 5 normal cycles within 60s the dispatcher was permanently disabled. Now only non-zero exits count. --- packages/ai/src/bridge.ts | 6 ++-- tests/unit/ai/bridge.test.ts | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/bridge.ts b/packages/ai/src/bridge.ts index 398ef3e7..c54ab084 100644 --- a/packages/ai/src/bridge.ts +++ b/packages/ai/src/bridge.ts @@ -216,12 +216,14 @@ function startDispatcher(): ChildProcess | null { dispatcherReady = false; }); - child.on("close", () => { + child.on("close", (code) => { for (const [id, req] of pendingRequests.entries()) { req.reject(new Error("Python dispatcher exited unexpectedly")); pendingRequests.delete(id); } - recordCrash(); + if (code !== 0) { + recordCrash(); + } dispatcher = null; dispatcherReady = false; }); diff --git a/tests/unit/ai/bridge.test.ts b/tests/unit/ai/bridge.test.ts index 72b84240..df56142b 100644 --- a/tests/unit/ai/bridge.test.ts +++ b/tests/unit/ai/bridge.test.ts @@ -1035,6 +1035,59 @@ describe("bridge - dispatcher lifecycle via runPythonWithProgress", () => { // The important thing is no crash -- the line is collected for pending requests }); + it("does not count exit code 0 as a crash (normal MAX_REQUESTS restart)", async () => { + const mockDispatcher = createMockProcess(); + const mockPerReq = createMockProcess(); + let callCount = 0; + + vi.mocked(spawn).mockImplementation(() => { + callCount++; + if (callCount === 1) return mockDispatcher.process; + return mockPerReq.process; + }); + + const promise = runPythonWithProgress("test.py", []); + + // Dispatcher exits with code 0 (normal MAX_REQUESTS shutdown) + mockDispatcher.emitEvent("close", 0, null); + + await new Promise((r) => setTimeout(r, 10)); + + mockPerReq.stdout.emit("data", Buffer.from('{"ok": true}\n')); + mockPerReq.emitEvent("close", 0, null); + await promise; + + const status = getDispatcherStatus(); + expect(status.consecutiveCrashes).toBe(0); + expect(status.failed).toBe(false); + }); + + it("still counts non-zero exit codes as crashes", async () => { + const mockDispatcher = createMockProcess(); + const mockPerReq = createMockProcess(); + let callCount = 0; + + vi.mocked(spawn).mockImplementation(() => { + callCount++; + if (callCount === 1) return mockDispatcher.process; + return mockPerReq.process; + }); + + const promise = runPythonWithProgress("test.py", []); + + // Dispatcher exits with code 1 (real crash) + mockDispatcher.emitEvent("close", 1, null); + + await new Promise((r) => setTimeout(r, 10)); + + mockPerReq.stdout.emit("data", Buffer.from('{"ok": true}\n')); + mockPerReq.emitEvent("close", 0, null); + await promise; + + const status = getDispatcherStatus(); + expect(status.consecutiveCrashes).toBeGreaterThanOrEqual(1); + }); + it("per-request fallback retries with python3 when venv python fails with ENOENT", async () => { const mockDispatcher = createMockProcess(); const mockVenvPython = createMockProcess();