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 <triagent@localhost>
This commit is contained in:
robopipi[bot] 2026-11-03 09:32:52 +01:00 • committed by GitHub
parent d7704516e0
commit 90b42bca6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 3 deletions

View file

@ -185,11 +185,15 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
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 });
});
}

View file

@ -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;