From 90b42bca6ed161b8e7ee40ceda9755205c2882ec Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:32:52 +0100 Subject: [PATCH] fix(ai): remove Codex SSE retry abort listener after backoff (#11725) The Codex Responses provider's local `sleep()` helper attached an anonymous `abort` listener to the caller's signal and never removed it when the timer fired. The SSE retry loop calls this helper before each retry, so every completed backoff left one listener on the signal. Three 429 responses followed by a success left three listeners behind. The helper now uses a named `onAbort` handler, removes it when the timer fires, and registers it with `{ once: true }`. It still rejects with `Error("Request was aborted")`, which the retry loop's catch block depends on. ### Testing - New regression test in `packages/ai/test/openai-codex-stream.test.ts`. A stub fetch returns 429 with `retry-after: 0` three times and then a completed SSE stream, using `maxRetries: 3` and the caller's own `AbortController`. The test asserts that `getEventListeners(signal, "abort")` is empty afterwards. It fails without the change ("got 3") and passes with it. - `node ../../node_modules/vitest/dist/cli.js --run test/openai-codex-stream.test.ts`: 41/41 pass. - A standalone script against the built package printed `listeners 3` before the change and `listeners 0` after. - `npm run check`: biome and the repo scripts pass. `tsc` fails, but only in files this change does not touch: generated model ids in `packages/ai` tests, `github-copilot.ts`, and missing bench dependencies in `packages/schema`. I did not verify whether those failures also happen on a clean checkout. Fixes #11708 Requested by @badlogic Co-authored-by: triagent --- packages/ai/src/api/openai-codex-responses.ts | 10 +++- packages/ai/test/openai-codex-stream.test.ts | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 5165e1ccf..e226805d9 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -185,11 +185,15 @@ function sleep(ms: number, signal?: AbortSignal): Promise { reject(new Error("Request was aborted")); return; } - const timeout = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { + const onAbort = () => { clearTimeout(timeout); reject(new Error("Request was aborted")); - }); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index fbb6c10a4..23b26088b 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1,3 +1,4 @@ +import { getEventListeners } from "node:events"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -320,6 +321,61 @@ describe("openai-codex streaming", () => { expect(result.endTurn).toBe(false); }); + // Regression test for #11708 + it("removes SSE retry backoff abort listeners after the retry succeeds", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); + process.env.PI_CODING_AGENT_DIR = tempDir; + const token = mockToken(); + let calls = 0; + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + return new Response("not found", { status: 404 }); + } + calls++; + if (calls <= 3) { + return new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "retry-after": "0" }, + }); + } + return new Response(buildSSEPayload({ status: "completed" }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context = normalizeContext({ + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }); + + const controller = new AbortController(); + const result = await streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + maxRetries: 3, + signal: controller.signal, + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(calls).toBe(4); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + }); + it("maps response.incomplete to stopReason length even when the SSE body stays open", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir;