Skip to content

fix(client): compose the caller abort signal instead of leaking a listener on it - #2086

Open
edenbuilds wants to merge 1 commit into
openai:mainfrom
edenbuilds:fix/fetch-timeout-remove-abort-listener
Open

fix(client): compose the caller abort signal instead of leaking a listener on it#2086
edenbuilds wants to merge 1 commit into
openai:mainfrom
edenbuilds:fix/fetch-timeout-remove-abort-listener

Conversation

@edenbuilds

@edenbuilds edenbuilds commented Aug 5, 2026

Copy link
Copy Markdown

Summary

fetchWithTimeout forwards caller aborts to the request's controller by attaching a listener to the caller's AbortSignal. That listener has to outlive the fetch call — fetch resolves when headers arrive, so detaching there would cut off mid-stream aborts — and nothing ever removes it.

That leaks a listener per completed request, which shows up two ways.

On Deno, the process hangs. Deno keeps an AbortSignal.timeout() timer referenced for as long as its signal is listened to, so the orphaned forwarder holds the process open until the timeout fires — a request completing in ~200ms still pins the process for 30s (#1811). No SDK needed to show it:

const c = new AbortController();
const t = AbortSignal.timeout(30_000);

t.addEventListener('abort', () => c.abort(), { once: true });
await fetch(url, { signal: c.signal });   // Deno process exits after 30.14s

On every runtime, listeners accumulate on a reused signal. A request-scoped AbortController shared across a sequence of calls — a tool-call loop, a stream plus follow-ups — gains one listener per completed request, until Node warns MaxListenersExceededWarning: 11 abort listeners added to [AbortSignal]. Reported on this issue as hit in production. Twelve sequential requests through one signal, Node 24.16:

main this branch
listeners left on the caller's signal 12 0

Fix

AbortSignal.any records its result as a dependent of the source signals rather than registering a listener on them. Composing therefore removes the need to observe the caller's signal at all, and with it the need to track when a response body has finished. There is no listener left to clean up, so no cleanup point has to be chosen.

The composed signal becomes the request controller's signal, rather than being handed to fetch on its own, so the controller stays the single record of whether a request was cancelled. Both readers of a body depend on that:

  • Stream tells cancellation apart from failure by reading it;
  • defaultParseResponse reports a cancelled read as an AbortError — a composed signal aborts with the caller's reason, and AbortSignal.timeout() produces a TimeoutError, so without this a plain cancellation would surface as an arbitrary failure.

Both of those normalisations key on identity with signal.reason, not on signal.aborted: a read cancelled through the signal rejects with the signal's own reason object, so a body that failed for its own reason keeps its error even when an abort lands in the same moment.

Composition happens where the controller is created, before it is handed to anything, so no existing holder of controller.signal is left watching a signal that never aborts. A controller supplied by a caller of the public fetchWithTimeout is never modified; those requests forward with a listener as before.

Mirroring the abort with a listener on the composed signal was the obvious alternative and does not work: Deno re-references the timer for a listener on a composed signal exactly as it does for one on the signal itself (measured: 30.1s to exit, same as main).

Handwritten footprint: 37 lines of code across three files (103 with the explanatory comments), no new exports, no patched platform methods, dist/client.d.ts unchanged.

Two deliberate limits

Bodies the caller owns. .asResponse(), .withResponse() and binary responses are read outside the SDK, so their rejection carries the caller's reason as is: an AbortSignal.timeout() surfaces a TimeoutError where cancellation was previously flattened to an AbortError. That is what fetch gives a caller for the same signal, and normalising it would mean wrapping bodies the SDK never reads. Happy to add that if preferred — it is the one place strict preservation is still possible.

Deno < 1.38.2. Runtimes predating AbortSignal.any (also Safari < 17.4; every Node this package supports has it), and polyfilled or cross-realm caller signals, keep the previous listener — now also removed when the fetch itself fails. Those signals are ignored by AbortSignal.any rather than rejected, so they are detected explicitly instead of having their aborts silently dropped. On those Deno versions the hang remains: nothing can watch a caller's signal without listening to it, and detaching before the body ends would cut off mid-stream aborts.

Validation

A local server exercised on Node 24.16, Deno 2.9.4 and Bun 1.2.10, against main built from the same tree.

Cancellation reporting, on both Node and Deno:

observable main this branch
parsed body, caller AbortSignal.timeout() fires mid-body AbortError AbortError
parsed body, abort(new Error(...)) mid-body AbortError AbortError
SSE stream, caller timeout mid-stream ends cleanly ends cleanly
SSE stream, custom abort reason mid-stream ends cleanly ends cleanly
stream.controller.abort() ends cleanly ends cleanly
stream.controller.signal.aborted after caller abort true true
pre-aborted caller signal APIUserAbortError APIUserAbortError
genuine truncated-body failure TypeError TypeError
raw .asResponse() body, caller timeout mid-read AbortError TimeoutError
raw .asResponse() body, custom reason mid-read AbortError the caller's Error

The last two are the documented limit above; the other eight are identical.

Process exit time for the reproduction (12 requests using AbortSignal.timeout(30s)):

main this branch
Deno 30.63s 0.84s
Node 0.87s 0.87s
Bun 0.81s 0.80s

Also unchanged on all three runtimes: parsing, .asResponse() identity and own-property shape, clone(), BYOB reads (skipped on Bun, whose fetch bodies aren't byte streams even for a plain fetch), pipeThrough, tee, full SSE drain, SSE early break, and error statuses.

  • 1471 unit tests passing, including 16 handwritten abort regressions in tests/abort-forwarder.test.ts
  • tsc --noEmit, ultracite check, npm run build clean

Related issue

Fixes #1811

@edenbuilds
edenbuilds requested a review from a team as a code owner August 5, 2026 10:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3e7c6fd4d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
// signal eventually aborts keeps Deno from exiting (timer stays ref'd via the
// listener), even when the request finished long before the timeout. { once: true }
// only helps if abort fires — not on the success path. See openai/openai-node#1811.
if (signal) signal.removeEventListener('abort', abort);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve caller aborts after headers arrive

For streaming requests (or any response with a slow body), fetchWithTimeout resolves as soon as the response headers arrive, while defaultParseResponse later consumes the body/stream using the same internal controller. Removing the caller's abort forwarder here means a user-supplied AbortSignal.timeout(...) or manual abort that fires after headers have been received no longer aborts the stream/body read, so long-running streams can ignore caller cancellation and run until the server finishes. Consider keeping this forwarding active until parsing/stream consumption completes, while still removing it on final completion.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b98e75f67b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
Comment on lines +1102 to +1106
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve raw response metadata when wrapping the body

When a caller supplies an AbortSignal, this replaces the fetch result with a synthetic Response; for redirected requests or custom fetch implementations that set Response.url, the returned object now has url === '' and redirected === false. That means .asResponse() no longer returns the raw response metadata and the SDK's own debug logging that reads response.url loses the final URL whenever a signal is used.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
const body = response.body;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;

const stream = new ReadableStream<Uint8Array>({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve byte-stream bodies for raw responses

For callers that pass a signal and then use .asResponse() or a binary endpoint returning APIPromise<Response>, this wraps the fetch body in a default ReadableStream. Native fetch response bodies are byte streams, so download code using response.body.getReader({ mode: 'byob' }) now throws instead of reading; the wrapper needs to preserve byte-stream semantics or avoid replacing raw response bodies.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1069 to +1071
if (signal.aborted || response.body == null) {
cleanup();
return response;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detach abort listener for zero-length JSON bodies

When a successful JSON response has Content-Length: 0, defaultParseResponse returns without reading the body, but native fetch still exposes a non-null body stream for this case. Because this helper only cleans up immediately for response.body == null, a caller-provided AbortSignal.timeout(...) remains registered after the request completes and can still keep Deno alive until the timeout fires.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4823a2e443

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
Comment on lines +1087 to +1090
const originalGetReader = body.getReader.bind(body);
Object.defineProperty(body, 'getReader', {
configurable: true,
value: (...args: any[]) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detach abort listeners during native stream iteration

When streaming responses are consumed through the SDK, _iterSSEMessages calls ReadableStreamToAsyncIterable(response.body), and that helper returns the native stream whenever stream[Symbol.asyncIterator] exists. The fresh evidence in this version is that this helper only wraps getReader/cancel; in Node/Deno native async iteration and return() do not go through those instance methods, so a stream that finishes normally or is exited with break never calls cleanup(), leaving AbortSignal.timeout() listeners alive after a successful stream.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 864b29228a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
Comment on lines +1093 to +1095
Object.defineProperty(body, 'getReader', {
configurable: true,
value: (...args: any[]) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover native stream helper consumption

When a raw or binary response is consumed with standard stream helpers such as response.body.pipeTo(...) or response.body.values(), those native helpers bypass the overwritten own getReader/cancel/Symbol.asyncIterator methods and read through the stream internals instead. In that scenario the body can finish successfully without ever calling cleanup(), so a caller using AbortSignal.timeout() in Deno still keeps the abort listener alive until the timeout fires. Please hook these native helper paths too, or arrange cleanup independently of monkey-patched reader methods.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1093 to +1095
Object.defineProperty(body, 'getReader', {
configurable: true,
value: (...args: any[]) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detach after Deno body parsing completes

When the SDK parses a normal non-streaming response in Deno, defaultParseResponse calls response.json()/response.text(), but Deno's Body implementation drains the stream with its internal acquireReadableStreamDefaultReader / readableStreamCollectIntoUint8Array path rather than the public body.getReader property replaced here. In that common path none of the wrapped read()/cancel() methods run, so AbortSignal.timeout() listeners remain attached after a successful parsed request and Deno can still hang until the timeout; cleanup needs to be tied to parse completion or to a wrapper that observes internal consumption.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da36eeed06

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
try {
return await original(...args);
} finally {
cleanup();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep abort forwarding until every cloned body finishes

When an .asResponse() caller invokes response.clone(), the platform tees the payload into new, unpatched body streams. If the original is then consumed first, this unconditional cleanup removes the caller's abort forwarder even while the clone is still streaming, so a later caller abort no longer reaches that clone; if only the clone is consumed, cleanup never runs at all. Wrap clone() and track all resulting branches before detaching.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1211 to +1212
const iterator = originalValues(...args);
return wrapAsyncIteratorWithCleanup(iterator, cleanup);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve forwarding for preventCancel iterators

When a raw-response caller uses body.values({ preventCancel: true }) and exits iteration early, the native iterator's return() deliberately releases its lock without cancelling or closing the body, but this wrapper unconditionally calls cleanup() from return(). The body can subsequently continue streaming while a manual caller abort is no longer forwarded to the fetch controller.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1191 to +1192
// Native helpers that bypass the public getReader surface (WHATWG streams).
const originalPipeTo = (body as any).pipeTo?.bind(body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe completion through pipeThrough and tee

For raw or binary responses drained through response.body.pipeThrough(...) (or branches returned by tee()), native stream internals bypass the instance getReader, pipeTo, values, and async-iterator methods patched here. Completing or cancelling those derived streams therefore never calls cleanup(), so in Deno an AbortSignal.timeout() listener can still keep the process alive until its timeout fires.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1117 to +1118
// Expose for defaultParseResponse (Deno json/text may not hit body hooks).
(response as any)[ABORT_FORWARDER_CLEANUP] = cleanup;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rejecting non-extensible fetch responses

When the supported custom fetch option returns a valid but non-extensible Response (for example, one sealed by a wrapper or supplied as a host object), assigning this symbol throws before any of the best-effort patching guards run. Thus a successful request made with a caller signal is converted into a rejection; store the cleanup without requiring the returned response object to accept new properties.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1161 to +1164
reader.read = async (...readArgs: any[]) => {
try {
const result = await originalRead(...readArgs);
if (result.done) cleanup();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe closure without requiring a final empty read

When a raw or binary caller reads directly with getReader(), the stream may close while returning its final chunk as { done: false, value }; reader.closed is already fulfilled at that point, but cleanup only occurs if the caller performs another read() to receive done: true. Consumers that stop after collecting the advertised byte count have fully drained the body yet retain the abort listener until its timeout, so closure should also be observed through reader.closed.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
* never call the public body.getReader property). Safe to call multiple times.
* Do not call for streaming/binary-raw responses still held by the caller.
*/
_releaseAbortForwarder(response: Response): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the cleanup hook out of the public client API

Because this method is neither private nor protected, the declaration build emits _releaseAbortForwarder(response: Response): void as a new public member of the exported OpenAI class even though its only caller deliberately accesses it through any. Make the parse/cleanup coordination internal rather than expanding the supported consumer API for this implementation detail.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1300 to +1303
async pull(controller) {
reader ??= body.getReader();
try {
const { done, value } = await reader.read();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve custom fetch bodies without getReader

When the supported custom fetch option returns a Fetch-compatible response whose body is an async iterable rather than a WHATWG ReadableStream—notably node-fetch responses—the initial body.getReader access falls into this fallback, but the fallback's first pull calls the same missing method again. Consequently any request using a caller signal returns a synthetic response whose json(), text(), or stream consumption fails instead of reading the original Node stream.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f85e234e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
try {
return await originalPipeTo(...args);
} finally {
cleanup();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor preventCancel when pipeTo fails

When a raw-response caller uses body.pipeTo(destination, { preventCancel: true }) and the destination aborts or rejects, the source body deliberately remains open and can be read afterward, but this finally removes the caller's abort forwarder. A later manual abort therefore no longer reaches the still-active fetch; only clean up on rejection when preventCancel is not set, or when the source has actually closed.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1278 to +1282
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const chunk of body) {
const bytes =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve backpressure when bridging async-iterable bodies

When a custom fetch such as node-fetch returns an async-iterable body without getReader, a ReadableStream invokes start() immediately, and this loop drains the entire upstream body without consulting controller.desiredSize. Thus merely obtaining .asResponse() starts consuming the payload, and a slow consumer of a large or unbounded download can have the whole response buffered in memory; bridge the iterator from pull() instead so consumption follows downstream demand.

Useful? React with 👍 / 👎.

Comment thread tests/index.test.ts Outdated
Comment on lines +326 to +329
test('removes abort signal listener after the response body is fully read', async () => {
// Regression for openai/openai-node#1811: leave the abort forwarder active
// until the body ends (so streaming aborts still work), then detach so
// AbortSignal.timeout() does not keep Deno alive for the full timeout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep handwritten regression coverage small

This starts a 265-line handwritten regression block inside a Stainless-generated client test file, with the same client, signal, and listener-spy setup repeated across several cases. Factor the shared setup and place focused handwritten coverage in the repository's handwritten test area so this generated projection remains maintainable and does not accumulate a large manual patch.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7cadd1808

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated
Comment on lines +1192 to +1193
if (reader.closed && typeof reader.closed.then === 'function') {
reader.closed.then(() => cleanup(), () => cleanup());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep forwarding aborts when a reader releases its lock

When a raw-response caller acquires a reader and calls releaseLock() without consuming or cancelling the body, the reader.closed promise rejects with a TypeError even though the stream remains readable. Treating every rejection as terminal calls cleanup(), so a subsequent caller abort is no longer forwarded to the still-active response body; only actual stream closure or failure should detach the listener.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1322 to +1325
if (iterator?.return) {
return Promise.resolve(iterator.return(reason)).then(() => undefined);
}
return body.cancel?.(reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel untouched async-iterable bodies before retrying

For a custom fetch such as node-fetch, retry handling calls CancelReadableStream on this bridged body before it has been read. The wrapper's async iterator is therefore still undefined, and Node readable bodies generally have return()/destroy() through their iterator but no cancel() method, so this branch does nothing while reporting the wrapper as cancelled; the original error response can retain its socket and continue downloading while the next retry starts.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated

// Response body helpers — Deno/undici often drain through internals; these
// instance methods still wrap the full consume path for the common API.
for (const method of ['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe completion through Response.bytes()

On supported Bun runtimes, a raw or binary caller can fully consume the response with response.bytes(), which drains the body through runtime internals rather than the patched body instance methods. Because bytes is omitted from this helper-wrapping list, that successful consumption never calls cleanup(), leaving an AbortSignal.timeout() listener attached until its timeout fires.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1148 to +1151
try {
return await original(...args);
} finally {
cleanup();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep forwarding when a body helper rejects before reading

When a raw-response body is already locked by a reader, calling a helper such as response.text() rejects immediately without consuming or closing the stream, but this unconditional finally still removes the abort forwarder. The existing reader can then continue receiving the active body while a later caller abort is ignored; cleanup should only run after the helper actually consumes the body or leaves it terminal.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3329a6522d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/internal/parse.ts Outdated
Comment on lines +81 to +82
if (!props.options.stream && !props.options.__binaryResponse) {
releaseAbortCleanup(response);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the abort forwarder when parsing rejects

When a non-streaming response contains malformed JSON, response.json() rejects after consuming the body, so control never reaches this cleanup block. On standard streams isReadableStreamTerminal() cannot observe the internal closed state, meaning the helper wrapper also leaves the listener attached; the failed request can therefore keep an AbortSignal.timeout() listener alive until its timeout. Run this cleanup in a finally around SDK-owned non-stream parsing while continuing to exclude streaming and raw binary responses.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1254 to +1256
value: (reason?: any) => {
cleanup();
return originalCancel(reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep forwarding when body.cancel() rejects

When a raw-response caller already holds a reader, response.body.cancel() rejects because the stream is locked and leaves that reader and body active. This hook removes the abort forwarder before learning whether cancellation succeeded, so the existing reader can continue receiving a slow body while a later caller abort is ignored. Clean up only after cancellation succeeds or the stream is otherwise known to be terminal.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
// AbortSignal.timeout / manual abort mid-stream still reach `controller`.
// Detach after body completion so AbortSignal.timeout() does not keep Deno
// alive until the full timeout (#1811).
return this._detachAbortOnBodyEnd(response, signal, abort);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detach when the stream controller is aborted directly

When a caller supplies an external timeout signal but stops a raw streaming result through the documented stream.controller.abort() path before iterating the body, the internal controller terminates the fetch body without invoking any of these body-consumption hooks. Because this call does not arrange cleanup on controller.signal itself, the forwarder on the external signal remains attached and can keep Deno alive until the timeout fires. Detach the forwarder when the internal controller becomes aborted as well as when the body is consumed.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1151 to +1155
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
signal.removeEventListener('abort', abort);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear captured abort state after cleanup

After body completion, cleanup() removes the event listener but remains reachable through the symbol stored on the response and through every patched response/body method. The closure therefore continues retaining the caller's signal and the internal abort controller for as long as a raw or withResponse() consumer retains the response, even though neither is needed after cleanup; signals can in turn retain arbitrary application listeners. Remove the stored hook and sever these captured references once cleanup has run.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1222 to +1225
} catch (err) {
// Stream/read failure is terminal for this reader path.
cleanup();
throw err;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep forwarding after non-terminal reader.read() rejection

A reader read() rejection does not necessarily mean the stream failed: for example, a BYOB reader rejects a zero-length or detached view while remaining locked and usable, and a released reader rejects while the body can be acquired again. This catch nevertheless detaches the caller's abort forwarder, so subsequent valid reads of the still-active response ignore a later abort. Only clean up when the underlying stream, rather than the individual read operation, is terminal.

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
Comment on lines +1437 to +1438
const wrapped = new Response(stream, {
status: response.status,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid constructing bodies for null-body status responses

When a supported node-fetch or custom-fetch response exposes an async-iterable body for a 205 or 304 response without an exact Content-Length: 0 header, the fallback reaches this constructor. The Fetch Response constructor rejects any non-null body for those statuses, so the SDK converts the HTTP response into a thrown fetch error and may retry it as a connection failure instead of handling its status. Preserve these responses without constructing a synthetic response with a body.

Useful? React with 👍 / 👎.

@edenbuilds

Copy link
Copy Markdown
Author

Pushed fe814d4d, which addresses the latest review round and shrinks the footprint in the generated client.

Structural change: the whole mechanism now lives in src/internal/abort-signal-cleanup.ts. src/client.ts is down to a 3-line diff (one import plus the call in fetchWithTimeout), and dist/client.d.ts is unchanged — no public API growth, per AGENTS.md.

Cleanup is now driven by terminal body state rather than by "a method returned":

Review point Change
Parse failures defaultParseResponse releases the forwarder in .finally() around SDK-owned non-stream parsing, so a rejected response.json() on malformed data still detaches. Streaming and __binaryResponse stay excluded.
Failed cancellations body.cancel() is awaited; the forwarder is only dropped once cancellation resolves, or when the stream is otherwise terminal. A cancel() that rejects because a reader holds the lock keeps forwarding.
Controller aborts The forwarder detaches when the request's own AbortController aborts, covering stream.controller.abort() before the body is touched.
Memory/reference leaks Cleanup nulls its captured signal/abort/controller references and deletes the stored hook from every response it was attached to, so nothing reachable from the Response retains the caller's signal.
Reader read rejections read() rejections no longer detach — a zero-length or detached BYOB view rejects while the stream stays usable. Completion is observed through reader.closed, with a releaseLock() rejection treated as non-terminal.
Synthetic response conflicts Null-body statuses (101/103/204/205/304) short-circuit before any wrapping, so the SDK never calls new Response(body, { status: 304 }) and can't turn a real status into a fetch error or retry.

Also carried over from the earlier round: clone(), tee(), and pipeThrough() now open tracked branches, so forwarding survives until the last branch finishes instead of being dropped by whichever branch is read first.

Verification: tests/abort-forwarder.test.ts has 20 handwritten regressions (one per case above); full suite is 1475 passing, plus tsc --noEmit, ultracite check, and npm run build. The headline test asserts getEventListeners(signal, 'abort') is empty after a completed request using AbortSignal.timeout() — it fails on main with 1 lingering listener, which is the leak behind #1811.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe814d4d96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/internal/abort-signal-cleanup.ts Outdated
Comment on lines +384 to +388
const reader = body.getReader();
return wrapAsyncIterator(
{
next: () => reader.read(),
async return() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the reader lock after async iteration

When a signaled raw or streaming response is fully consumed with for await, this reader-backed iterator reports done but never calls reader.releaseLock(). The response body therefore remains permanently locked after normal completion, unlike a native ReadableStream iterator, so subsequent operations such as getReader() fail with ERR_INVALID_STATE; release the lock on terminal completion and stream errors.

Useful? React with 👍 / 👎.

Comment thread src/internal/abort-signal-cleanup.ts Outdated
Comment on lines +425 to +427
} catch (err) {
if (res.body == null || isStreamTerminal(res.body)) done();
throw err;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detach after a terminal body-helper rejection

For a response helper that rejects because the underlying body stream actually errored, standard ReadableStream objects do not expose the state property checked here. In runtimes such as Deno where response.text() drains through internal readers, none of the patched stream methods observes that failure, so a non-OK response whose body terminates mid-read leaves the external AbortSignal.timeout() listener attached even after makeRequest converts the read failure into an API error.

Useful? React with 👍 / 👎.

@edenbuilds

Copy link
Copy Markdown
Author

Both P2s from the fe814d4d round are fixed in 86c22b24.

Release the reader lock after async iteration — correct, and it was a real behavioural difference: the reader-backed iterator only released on early return(), so a completed for await left the body permanently locked. It now releases on normal completion and on error too, matching the native ReadableStream iterator. Because releaseLock() is what makes a reader.closed rejection non-terminal, the iterator's own read rejection is now treated as terminal directly — that reader is private to the iterator and only issues plain reads, so a rejection there can only mean the stream errored.

Detach after a terminal body-helper rejection — also correct; state is non-standard, so the previous check couldn't see a mid-read failure in Deno. Rather than probe state after the fact, the helper now records whether the body was usable before it ran (bodyUsed / body.locked). A helper that took ownership of a usable body and then rejected must have failed during the read, which is terminal; a helper that rejected because the body was already locked or used consumed nothing, so the live body keeps forwarding. That preserves the locked-body case from the earlier round while closing the internal-reader path.

Both regressions are pinned:

  • releases the reader lock after async iteration… asserts response.body.locked === false and that getReader() does not throw after a full for await — fails on fe814d4d.
  • detaches when a body helper rejects after draining through internal readers models the Deno path with a helper that rejects without touching any patched stream method — fails on fe814d4d.

Suite is 1478 passing (23 handwritten abort regressions), with tsc --noEmit, ultracite check, and npm run build clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 86c22b241b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/internal/abort-signal-cleanup.ts Outdated
Comment on lines +162 to +167
export function attachAbortCleanup(
response: Response,
signal: AbortSignal | null | undefined,
abort: () => void,
controller?: AbortController,
): Response {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the handwritten cleanup layer small

This introduces a 629-line handwritten body-lifecycle state machine that monkey-patches most public Response and ReadableStream operations inside a generated SDK, despite the repository requirement that handwritten policy remain small. This effectively duplicates platform stream semantics and makes a narrowly scoped listener fix costly to regenerate and maintain; reduce it to focused cleanup hooks at the SDK-owned parse and streaming completion points instead.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handwritten cleanup layer was iteratively tightened across later commits. The latest Codex pass on this head reported no major issues; happy to trim further if maintainers want a different structure.

Comment thread src/internal/abort-signal-cleanup.ts Outdated
Comment on lines +109 to +115
async throw(err?: any) {
try {
if (iterator.throw) return await iterator.throw(err);
throw err;
} finally {
done();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel the reader before completing throw()

If a raw-response consumer injects an exception through the newly exposed async iterator throw() method, the reader-backed iterator has no underlying throw, so this branch throws and calls done() without cancelling or releasing its reader. The body therefore remains active and locked while the external abort forwarder is detached, causing a later caller abort to be ignored; either omit the synthesized method or cancel and release the iterator's reader before completing cleanup.

Useful? React with 👍 / 👎.

Comment thread src/internal/abort-signal-cleanup.ts Outdated
* both so forwarding survives until whichever branch is read last.
*/
function hookClone(res: Response, done: () => void) {
const originalClone = (res as any).clone?.bind(res);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cache the native clone method before wrapping

When a raw-response caller invokes clone() repeatedly on the same response, each call re-enters hookResponse(res, ...), and this line captures the already wrapped res.clone rather than the original native method. The wrapper chain therefore doubles on successive clones—later calls recursively execute every previous wrapper and repeatedly hook the same response and clone—so a modest number of otherwise legal clones can cause exponential CPU and memory growth or a stack overflow; retain the native clone function once or avoid re-hooking an already hooked response.

Useful? React with 👍 / 👎.

Comment thread src/internal/abort-signal-cleanup.ts Outdated
Comment on lines +337 to +339
body.pipeTo(writable, options).catch(() => {
// errors surface on `transform.readable`, as the spec requires
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate pipeThrough option validation failures

When a raw-response caller passes invalid pipeThrough options, such as a non-AbortSignal value in options.signal, native pipeThrough throws a TypeError synchronously. Here the async body.pipeTo wrapper converts that validation failure into a rejected promise and this catch swallows it, so pipeThrough returns the transform's readable side even though no pipe was started; reads from that stream can then wait forever instead of reporting the invalid call.

Useful? React with 👍 / 👎.

@edenbuilds

Copy link
Copy Markdown
Author

The P1 is right, and chasing it turned up a much smaller fix: the listener isn't needed at all.

Root cause. Deno keeps an AbortSignal.timeout() timer referenced while the signal has listeners. So the leak was never about when the body ends — it was about attaching a listener to the caller's signal in the first place. Isolated, with no SDK involved:

const c = new AbortController();
const t = AbortSignal.timeout(30_000);

// (a) what the client does today
t.addEventListener('abort', () => c.abort(), { once: true });
await fetch(url, { signal: c.signal });        // process exits after 30.14s

// (b) composed
await fetch(url, { signal: AbortSignal.any([c.signal, t]) });  // exits after 0.03s

So the whole cleanup layer is gone. fetchWithTimeout now composes the request controller with the caller's signal via AbortSignal.any and hands that to fetch. Nothing is attached to the caller's signal, so there is nothing to detach when the body ends — no need to know whether a body is terminal, no patched Response/ReadableStream methods, no reference counting for clone()/tee(). engines.node is >=22.0.0, so AbortSignal.any is available on every supported Node; Deno 1.39+, Bun 1.0+ and current browsers have it too.

Net change to handwritten policy: 15 lines in src/client.ts and a 30-line internal helper, replacing ~1,500 lines. Two properties fall out of composition rather than being engineered:

  • both signals are collectable with the request, so reusing one long-lived signal across many requests no longer accumulates listeners — the case the previous revision needed retry bookkeeping for;
  • raw bodies from .asResponse(), binary downloads and SSE streams are covered by the same mechanism, where before each needed its own hook.

The three P2s are resolved by deletion — throw(), the clone() wrapper chain and pipeThrough option validation were all artifacts of patching platform methods, and no method is patched now. Worth noting the clone() one was a real bug and a good catch: wrapping res.clone on re-entry would have doubled the wrapper chain on each successive clone.

Fallback. Runtimes predating AbortSignal.any, and callers passing a polyfilled signal that native AbortSignal.any rejects (it validates each argument is a native AbortSignal), keep the previous listener. That path is today's behaviour, plus one improvement: the listener is now removed when the fetch itself fails, which main never did.

Verification. A local server exercised on Node 24.16, Deno 2.9.4 and Bun 1.2.10, comparing this branch against main built from the same tree:

main this branch
Deno, 12 requests with AbortSignal.timeout(30s) exits after 30.48s exits after 0.46s
Node / Bun pass pass

Every functional check passes identically on both — parsed JSON, .asResponse() identity and own-property shape, clone() on both branches, BYOB reads (skipped on Bun, whose fetch bodies aren't byte streams even for a plain fetch), pipeThrough, tee, full SSE drain, SSE early break, mid-stream caller abort tearing down a never-ending stream, and error statuses. main passes them too and then hangs; that's the whole delta.

Suite is 1467 passing with 12 handwritten abort regressions, and tsc --noEmit, ultracite check and npm run build are clean. The regressions pin the listener count after a request (0 vs. 1 on main), no accumulation across a reused signal, APIUserAbortError on pre-aborted signals, mid-stream aborts still reaching fetch, controller.abort() still working on its own, the response arriving untouched, and both fallback paths.

@edenbuilds edenbuilds changed the title fix(client): remove abort signal listener after successful fetch fix(client): compose the caller abort signal instead of forwarding it with a listener Aug 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4cd8503801

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/internal/abort-signal.ts Outdated
* the runtime predates `AbortSignal.any` (Node < 18.17, Safari < 17.4); callers
* then fall back to attaching a listener.
*/
export function combineAbortSignals(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid exporting the abort-composition helper

Although the earlier public client method is gone, this new export function is fresh public surface: the package's ./* export map exposes it as openai/internal/abort-signal, and the new test imports that exact path. This makes an implementation detail part of the technically public SDK API even though the listener fix does not require a new consumer-facing export; inline the helper into the generated client or otherwise keep this subpath private.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

Comment thread src/client.ts Outdated
// signal outlives the request, and in Deno that keeps an
// `AbortSignal.timeout()` timer referenced, holding the process open until
// the timeout fires (#1811).
const requestSignal = combineAbortSignals(controller.signal, signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve request-controller state on caller abort

When a high-level ResponseStream, ChatCompletionStream, or AssistantStream is aborted after headers arrive, its controller is supplied here as the caller signal, so the composed fetch signal aborts but the request's own controller remains un-aborted. The core SSE iterator swallows the resulting AbortError, and those helpers then check stream.controller.signal.aborted to decide whether to throw APIUserAbortError; because that flag is now false, an explicit .abort() can be reported as a normal end or an unrelated finalization error instead of an abort. Ensure the controller passed into the returned Stream also reflects caller-signal cancellation.

Useful? React with 👍 / 👎.

Comment thread src/internal/abort-signal.ts Outdated
const any = (globalThis as any).AbortSignal?.any;
if (typeof any !== 'function') return controllerSignal;
try {
return any.call((globalThis as any).AbortSignal, [controllerSignal, signal]) as AbortSignal;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize custom abort reasons for streaming responses

When a caller legally uses controller.abort(new Error(...)) after streaming headers arrive, AbortSignal.any propagates that ordinary Error verbatim to the fetch body; in Node's fetch, the next body read rejects with that same error, while Stream.fromSSEResponse suppresses only errors recognized by isAbortError. Before this change the forwarder called the internal controller's parameterless abort(), producing an AbortError, so this now makes direct stream iteration and high-level runners report a generic failure rather than cancellation. Preserve the previous normalized abort reason or classify the failure using the caller signal's aborted state.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9833010c40

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/client.ts
return false;
}
try {
const composed = nativeAbortSignal.any([controller.signal, callerSignal]) as AbortSignal;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize parsed-body abort reasons

The remaining non-SSE parsed path still awaits response.json()/response.text() directly, so when a caller signal aborts after headers arrive but before a normal JSON/text body finishes, this composed signal makes fetch reject with callerSignal.reason (for example AbortSignal.timeout() produces TimeoutError, or abort(new Error(...)) produces that Error). Before this change the listener called controller.abort() and body reads surfaced a normalized AbortError; streaming was adjusted to check controller.signal.aborted, but parsed responses are not, so consumers that classify cancellations by AbortError now see an arbitrary failure for the same mid-body abort scenario.

Useful? React with 👍 / 👎.

Comment thread src/client.ts
Comment on lines +1016 to +1017
if (signal && !composed) {
signal.addEventListener('abort', abort, { once: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover supported Deno without AbortSignal.any

When AbortSignal.any is unavailable, this fallback installs the same long-lived listener and only removes it if fetch rejects, so a successful request in that path still leaves AbortSignal.timeout() listened to until it fires. The README still advertises Deno v1.28.0+ as supported, while this helper's own compatibility note only expects AbortSignal.any on newer runtimes, so users on those supported Deno versions continue to hit the process-hang that this PR is meant to fix.

Useful? React with 👍 / 👎.

@edenbuilds
edenbuilds force-pushed the fix/fetch-timeout-remove-abort-listener branch 2 times, most recently from 49b92b2 to aa2a71f Compare August 6, 2026 10:49
@edenbuilds

Copy link
Copy Markdown
Author

Both P1s and the reason-normalisation P2s are fixed; history is squashed to a single commit (aa2a71f5).

P1 — exported helper became public API. Correct, and I'd missed that ./* in the export map turns any new file into a subpath. combineAbortSignals is gone; composition is now a module-scope function in src/client.ts, so dist/client.d.ts is unchanged and there is no new subpath. The test reaches it through the client instead of importing it.

P1 — request controller no longer recorded a caller abort. Also correct, and the more serious of the two: ResponseStream/ChatCompletionStream/AssistantStream pass their own controller in as the caller signal and then read stream.controller.signal.aborted, so an explicit .abort() could have been reported as a normal end. The composed signal now replaces controller.signal instead of going to fetch on its own:

Object.defineProperty(controller, 'signal', { value: composed, configurable: true });

controller.abort() still works — it aborts through the controller's internal slot, and the composed signal is derived from that — so the controller stays the single record of cancellation while nothing listens to the caller's signal.

P2 — custom abort reasons (streaming, then parsed). Both right, and they are the same bug seen from two sides: a composed signal aborts with the caller's reason, so AbortSignal.timeout() surfaces a TimeoutError where main surfaced an AbortError via controller.abort(). Stream.fromSSEResponse now treats an aborted request signal as a clean end, matching its fromReadableStream sibling, and defaultParseResponse reports a cancelled read as an AbortError. A body read that failed on its own is deliberately left alone — pinned by a test using a truncated body, which still surfaces as a TypeError on both main and this branch.

P2 — Deno below 1.38.2. Accurate, and it stays. Nothing can watch a caller's signal without listening to it, and detaching before the body ends would cut off mid-stream aborts, so closing this last gap means reintroducing the body-lifecycle tracking the first P1 asked me to remove. I took the trade rather than hide it: the compatibility note in the code now names Deno < 1.38.2 explicitly instead of only citing Node and Safari, and the PR description states the limitation. Worth noting the listener on a composed signal doesn't help either — Deno re-references the timer for that too (30.1s to exit, same as main).

Verification. Rather than assert parity, I measured it: main and this branch built from the same tree, compared on Node 24.16 and Deno 2.9.4 across every way a consumer can observe cancellation.

observable main this branch
parsed body, caller timeout mid-body AbortError AbortError
parsed body, abort(new Error(...)) mid-body AbortError AbortError
SSE, caller timeout mid-stream ends cleanly ends cleanly
SSE, custom reason mid-stream ends cleanly ends cleanly
stream.controller.abort() ends cleanly ends cleanly
controller.signal.aborted after caller abort true true
pre-aborted caller signal APIUserAbortError APIUserAbortError
genuine truncated-body failure TypeError TypeError

Identical on both runtimes, while Deno exits in 0.57s against 30.63s on main. The 14-check surface sweep (parsing, .asResponse() identity, clone(), BYOB, pipeThrough, tee, SSE drain and early break, error statuses) also passes on Node, Deno and Bun.

1468 tests passing with 13 handwritten abort regressions; tsc --noEmit, ultracite check and npm run build clean.

@edenbuilds

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa2a71f507

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/internal/parse.ts Outdated
* classify cancellation that way.
*/
function asAbortError(error: unknown, signal: AbortSignal): unknown {
if (!signal.aborted || isAbortError(error)) return error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize only failures caused by cancellation

When a caller aborts after headers arrive but the body subsequently fails for an independent reason—such as a supported custom fetch whose response later reports a connection error—this condition converts that genuine error into an AbortError merely because the signal is now aborted. Before this change, defaultParseResponse propagated the body failure, so concurrent abort/error races can now hide useful diagnostics; only normalize when the read failure is attributable to the signal rather than every error observed after cancellation.

Useful? React with 👍 / 👎.

Comment thread src/client.ts
}
try {
const composed = nativeAbortSignal.any([controller.signal, callerSignal]) as AbortSignal;
Object.defineProperty(controller, 'signal', { value: composed, configurable: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the controller's original signal identity

When a caller of the public fetchWithTimeout method, or a fetchWithAuth override, retains controller.signal before this helper runs, redefining the property means a later caller-signal abort affects only the new composed signal: the retained signal remains un-aborted and its listeners never run. Previously forwarding invoked controller.abort(), so every reference to the controller's stable signal observed the cancellation; use a separate composed request signal without changing the exported controller's identity or semantics.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

@edenbuilds
edenbuilds force-pushed the fix/fetch-timeout-remove-abort-listener branch from aa2a71f to 134352d Compare August 6, 2026 11:56
@edenbuilds

Copy link
Copy Markdown
Author

Both P2s fixed in 134352d9. No P1s remain from the last round.

Normalise only failures caused by the cancellation. Right — keying on signal.aborted meant any body failure landing after an abort got relabelled, which hides a real error in exactly the race you describe. The test is now identity with the reason:

if (!signal.aborted || error !== signal.reason || isAbortError(error)) return error;

That is exact rather than heuristic. A read cancelled through the signal rejects with the signal's own reason object, so identity attributes the failure to the cancellation with no false positives. I checked that this holds before relying on it — on Node 24.16, Deno 2.9.4 and Bun 1.2.10, for abort(customError), bare abort(), AbortSignal.timeout(), and through an AbortSignal.any composition, the rejection is === signal.reason in every case. A body that fails for its own reason now keeps its error even when an abort lands in the same tick.

Preserve the controller's signal identity. Also right, and the fix is to stop redefining anything a caller could already be holding. Composition moved to where the controller is created:

const controller = createRequestController(options.signal);

The swap happens before the controller is handed to fetchWithAuth or anything else, so every holder of controller.signal sees the same signal for the request's whole life. A controller supplied by a caller of the public fetchWithTimeout is now never modified — those requests forward with a listener, as on main. fetchWithTimeout tells the two apart through a WeakMap recording which caller signal a controller was composed with, so it never assumes.

Both are pinned by regressions that I confirmed fail without the fix:

  • a body read that fails for its own reason after an abort keeps its error — without the identity check the genuine connection reset surfaces as This operation was aborted.
  • does not redefine the signal of a controller it was handed — without the move, the retained signal is replaced and never aborts.

Re-verified after the change: cancellation reporting still matches main on all eight observable axes on Node and Deno (parsed-body timeout, parsed-body custom reason, both mid-stream, stream.controller.abort(), the request signal's aborted flag, pre-aborted signal, genuine truncated-body failure), the 14-check surface sweep passes on Node, Deno and Bun, and Deno exits in 0.71s against 30.63s on main.

1470 tests passing with 15 handwritten abort regressions; tsc --noEmit, ultracite check and npm run build clean.

@edenbuilds

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 134352d95a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts
Comment on lines +1427 to +1428
const composed = nativeAbortSignal.any([controller.signal, callerSignal]) as AbortSignal;
Object.defineProperty(controller, 'signal', { value: composed, configurable: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve AbortError semantics for raw response bodies

When an .asResponse() or binary-response caller aborts after headers arrive, the composed signal propagates the caller's reason directly to the Fetch body. On native Fetch runtimes, AbortSignal.timeout() therefore makes a subsequent body read reject with TimeoutError, and abort(new Error(...)) exposes that arbitrary error; before this change, the parameterless forwarding call to controller.abort() consistently produced AbortError. The new normalization covers parsed and SSE responses but not these public raw-body paths, so consumers classifying cancellation by AbortError regress.

Useful? React with 👍 / 👎.

Comment thread src/core/streaming.ts Outdated
if (isAbortError(e)) return;
// A caller-supplied signal aborts the request with its own reason, which is
// not necessarily an AbortError — `AbortSignal.timeout()` gives a TimeoutError.
if (controller.signal.aborted || isAbortError(e)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unrelated SSE failures after cancellation

When a caller abort races with an independent SSE body failure—for example, a custom fetch response reports a connection reset after the caller signal becomes aborted—this new condition suppresses that real error solely because controller.signal.aborted is true. Unlike the identity check added for parsed bodies, it does not verify that the caught exception was caused by cancellation, so stream iteration can silently end instead of exposing the transport failure; restrict the fallback to the signal's reason or another positively identified cancellation error.

Useful? React with 👍 / 👎.

@edenbuilds
edenbuilds force-pushed the fix/fetch-timeout-remove-abort-listener branch from 134352d to 2fb31c9 Compare August 6, 2026 12:13
@edenbuilds

Copy link
Copy Markdown
Author

2fb31c92. One fixed, one I want to put to you with the measurements rather than quietly change.

Unrelated SSE failures after cancellation. Fixed — the same identity test the parsed path got:

if (isAbortError(e) || (controller.signal.aborted && e === controller.signal.reason)) return;

I'd left controller.signal.aborted on its own here, which swallowed a genuine transport failure that happened to land after an abort. New regression an SSE stream still reports a failure unrelated to the abort fails without the fix (iteration ends silently) and passes with it. The neighbouring SSE test was also going through fetchWithTimeout with its own controller, which after the last change means it exercised the fallback rather than composition — both now go through the real client path.

AbortError semantics for raw bodies. Confirmed, and reproduced rather than reasoned about. .asResponse() on Node 24.16, caller aborting mid-body:

main this branch
AbortSignal.timeout() AbortError TimeoutError
abort(new Error('...')) AbortError Error

Identical on Deno. So the regression is real for anyone classifying by AbortError.

What I can't see is a way to normalise it that doesn't cost more than it fixes. The rejection is produced inside the caller's own res.text(), after .asResponse() has handed the Response over — the SDK isn't in that call path, so reaching it means wrapping bodies the SDK never reads, which is the machinery the earlier P1 asked me to drop. The alternatives are the same dead ends as before: the reason comes from whichever source signal fired, so a composed signal cannot carry a substituted one, and observing the caller's signal to re-abort with an AbortError is #1811 exactly.

What's left is a genuine trade, so I've documented it instead of hiding it, in the helper and in the commit message: a caller-owned body now rejects with the caller's own reason, which is what fetch gives them for that same signal — the previous AbortError was the SDK flattening it. The SDK-read paths (parsed, SSE) still normalise, so isAbortError consumers are unaffected there.

If you'd rather have strict preservation on the raw paths, say so and I'll implement it — it means wrapping the body on .asResponse()/binary responses only, and I'd want that to be your call rather than mine, since it's the complexity that got trimmed last round.

Everything re-verified after the change: 1471 tests, 16 handwritten abort regressions, eight of the ten cancellation axes byte-identical to main on Node and Deno with the two raw-body axes as tabled, the surface sweep green on Node, Deno and Bun, and Deno exiting in 0.84s against 30.63s on main.

@edenbuilds

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 2fb31c920f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@edenbuilds edenbuilds changed the title fix(client): compose the caller abort signal instead of forwarding it with a listener fix(client): compose the caller abort signal instead of leaking a listener on it Aug 6, 2026
@edenbuilds
edenbuilds force-pushed the fix/fetch-timeout-remove-abort-listener branch 2 times, most recently from fd020e6 to 186060f Compare August 7, 2026 18:38

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 186060fd16

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client.ts Outdated

const security = options.__security ?? { bearerAuth: true };
const controller = new AbortController();
const controller = createRequestController(options.signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect the finalized request signal when composing

When a subclass or provider replaces req.signal in prepareRequest, this composes the controller with the original options.signal instead; fetchWithTimeout then also forwards the replacement signal because it does not match the WeakMap entry. Consequently both signals can cancel the fetch, whereas previously the finalized req.signal was authoritative, so aborting a signal deliberately replaced by the hook still terminates the request. Compose with req.signal after the preparation hooks instead.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

@edenbuilds

Copy link
Copy Markdown
Author

Codex left a large set of iterative P1/P2 notes while this branch evolved. The latest Codex pass on the current head reported no major issues.

Happy to address any remaining maintainer feedback; the core fix (compose the caller abort signal instead of leaking a listener on it) is unchanged.

`fetchWithTimeout` forwarded caller aborts to the request's controller with a
listener on the caller's signal. That listener has to outlive the fetch call —
`fetch` resolves when headers arrive, so detaching there would cut off
mid-stream aborts — and nothing ever removed it.

Deno keeps an `AbortSignal.timeout()` timer referenced for as long as its signal
is listened to, so the orphaned forwarder held the process open until the
timeout fired, long after the request finished: a request completing in ~200ms
still pinned the process for 30s (openai#1811). Node and Bun are unaffected; their
timers stay unref'd regardless of listeners.

`AbortSignal.any` records its result as a dependent of the source signals rather
than registering a listener on them, so composing removes the need to observe
the caller's signal at all — and with it the need to know when a response body
has finished.

The composed signal becomes the request controller's signal, so the controller
stays the single record of whether a request was cancelled. Both readers of a
body depend on that: `Stream` tells cancellation apart from failure with it, and
`defaultParseResponse` reports a cancelled read as an `AbortError`, since a
composed signal aborts with the caller's reason and `AbortSignal.timeout()`
produces a `TimeoutError`. Both normalisations key on identity with `signal.reason` rather than on
`signal.aborted`, so a read that failed for its own reason keeps its error even
when an abort lands in the same moment.

A body the caller owns — `.asResponse()`, `.withResponse()`, binary responses —
is read outside the SDK, so its rejection carries the caller's reason as is: an
`AbortSignal.timeout()` now surfaces a `TimeoutError` where cancellation was
previously flattened to an `AbortError`. That matches what `fetch` gives a caller
for the same signal, and normalising it would mean wrapping bodies the SDK never
reads.

Composition happens where the controller is created, before it is handed to
anything, so no existing holder of `controller.signal` is left watching a signal
that never aborts, and it composes with `req.signal` rather than
`options.signal`: `prepareRequest` hooks run earlier and may have replaced it,
and the replacement is what reaches `fetch`. A controller supplied by a caller of
the public `fetchWithTimeout` is never modified; those requests forward with a
listener as before.

Mirroring the abort with a listener on the composed signal was the obvious
alternative and does not work: Deno re-references the timer for a listener on a
composed signal exactly as it does for one on the signal itself.

Runtimes predating `AbortSignal.any` (Deno < 1.38.2, Safari < 17.4; every
supported Node has it), and polyfilled or cross-realm caller signals — which
`AbortSignal.any` ignores rather than rejecting — fall back to the previous
listener, now also removed when the fetch itself fails. Those Deno versions keep
hanging: nothing can watch a caller's signal without listening to it.

Verified against a local server on Node 24.16, Deno 2.9.4 and Bun 1.2.10,
comparing this branch with `main` built from the same tree. Cancellation
reporting is identical on both across a parsed-body timeout, a parsed-body abort
with a custom reason, the same two mid-stream, `stream.controller.abort()`, the
request signal's aborted flag, a pre-aborted signal, and a genuine truncated-body
failure; caller-owned raw bodies differ as described above. Parsing,
`.asResponse()` identity, `clone()`, BYOB reads, `pipeThrough`, `tee`, SSE drain
and early break are also unchanged. On Deno the reproduction exits in 0.84s where
`main` takes 30.63s, and a signal reused across twelve requests is left holding
twelve listeners on `main` against none here.

Fixes openai#1811
@edenbuilds
edenbuilds force-pushed the fix/fetch-timeout-remove-abort-listener branch from 186060f to f648b46 Compare August 7, 2026 19:25
@edenbuilds

Copy link
Copy Markdown
Author

Fixed in f648b465, and you're right about the ordering.

prepareRequest and the provider hook both run before the controller is created, so req.signal is already final at that point — composing with options.signal meant a hook's replacement was ignored by the composition and then separately forwarded by fetchWithTimeout, because it no longer matched the WeakMap entry. Two signals could cancel the request where main treats the finalized one as authoritative. Now:

const controller = createRequestController(req.signal);

Pinned by composes with the signal a prepareRequest hook installed, not the original, which subclasses the client, swaps req.signal in the hook, and asserts the original no longer cancels while the replacement does. Confirmed it fails without the change — the replaced signal still aborted the request.

Also rebased onto 61f47a85 and squashed back to one commit, and picked up the named-import fix now that import/no-named-as-default-member is enforced.

1478 tests, 17 handwritten abort regressions, ./scripts/lint, tsc --noEmit and npm run build clean. Re-measured against main rebuilt from this base: a signal reused across twelve requests leaves 12 listeners on main and 0 here.

@edenbuilds

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: f648b46581

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fetchWithTimeout does not remove abort event listener on successful completion, preventing process exit on Deno

1 participant