From 0bdbe48de2ac9d7c58b3eeb210bd11c5fc54c976 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 11 Sep 2026 22:06:26 -0300 Subject: [PATCH] fix(providers): stop devin-cli spawn error from double-closing SSE controller (#12517) (#13261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit. Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them. - ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on #13243) - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK - complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline - 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs - `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for #13278 and #13276, annotated and landed on #13243 ⚠️ base-red inherited: #12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch. --- .../fixes/12517-devin-cli-sse-double-close.md | 1 + open-sse/executors/devin-cli.ts | 6 +- ...cutor-devin-cli-12517-double-close.test.ts | 55 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/12517-devin-cli-sse-double-close.md create mode 100644 tests/unit/executor-devin-cli-12517-double-close.test.ts diff --git a/changelog.d/fixes/12517-devin-cli-sse-double-close.md b/changelog.d/fixes/12517-devin-cli-sse-double-close.md new file mode 100644 index 0000000000..7663b8c90e --- /dev/null +++ b/changelog.d/fixes/12517-devin-cli-sse-double-close.md @@ -0,0 +1 @@ +- fix(providers): stop devin-cli spawn error from double-closing the SSE controller (#12517) diff --git a/open-sse/executors/devin-cli.ts b/open-sse/executors/devin-cli.ts index 34d26afa28..a3336831f8 100644 --- a/open-sse/executors/devin-cli.ts +++ b/open-sse/executors/devin-cli.ts @@ -170,11 +170,7 @@ export class DevinCliExecutor extends BaseExecutor { err.message.includes("ENOENT") || err.message.includes("not found") ? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.` : `Devin CLI spawn error: ${err.message}`; - emit( - `data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n` - ); - emit("data: [DONE]\n\n"); - controller.close(); + finish(msg); }); if (signal) { diff --git a/tests/unit/executor-devin-cli-12517-double-close.test.ts b/tests/unit/executor-devin-cli-12517-double-close.test.ts new file mode 100644 index 0000000000..f65522a20f --- /dev/null +++ b/tests/unit/executor-devin-cli-12517-double-close.test.ts @@ -0,0 +1,55 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../open-sse/executors/devin-cli.ts"); + +describe("DevinCliExecutor — #12517 spawn error must not double-close SSE controller", () => { + it("surfaces a sanitized SSE error and never fires uncaughtException on ENOENT spawn", async () => { + const previousBin = process.env.CLI_DEVIN_BIN; + process.env.CLI_DEVIN_BIN = "/nonexistent/absolute/path/to/devin-bin-12517"; + + let caught: unknown = null; + const onUncaught = (err: unknown) => { + caught = err; + }; + process.on("uncaughtException", onUncaught); + + try { + const executor = new mod.DevinCliExecutor(); + const { response } = await executor.execute({ + model: "devin-cli", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: {}, + signal: undefined, + log: undefined, + } as never); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let payload = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + payload += decoder.decode(value); + } + + assert.match(payload, /Devin CLI not found/); + assert.match(payload, /data: \[DONE\]/); + + // Give the child's async "close" event (which fires after "error" for a + // failed spawn) room to run before asserting no uncaughtException fired. + await new Promise((resolve) => setTimeout(resolve, 300)); + + assert.equal(caught, null, `expected no uncaughtException, got: ${String(caught)}`); + } finally { + process.removeListener("uncaughtException", onUncaught); + if (previousBin === undefined) delete process.env.CLI_DEVIN_BIN; + else process.env.CLI_DEVIN_BIN = previousBin; + } + }); + + after(() => { + delete process.env.CLI_DEVIN_BIN; + }); +});