Skip to content

AbortSignal is ignored when it fires after the response is buffered (non-streaming fetch) #232

Description

@bengeorge

Before submitting a new issue

  • I tested using the latest version of the library, as the bug might be already fixed.
  • I checked for possible duplicate issues, with possible answers.

Bug summary

fetch() resolves as a normal success after its AbortSignal has already fired, so the caller receives — and parses — a response for a request it abandoned.

The abort listener does ask native to cancel:

abortListener = () => { client!.cancelRequest(requestId); };
signal.addEventListener('abort', abortListener, { once: true });

But UrlRequest.cancel() (Cronet) and URLSessionTask.cancel() are best-effort, and both are documented no-ops once the request has completed. When the cancel loses that race, nitroFetchRaw never re-checks the signal on the success path:

const res = await client.request(req);

return res;                                     // no signal check
} catch (e) {
  if (signal?.aborted) throw createAbortError(); // only the catch guards

So await res.json() runs on the caller's thread for a response nobody is waiting for — exactly the work the abort was meant to skip. nitroStreamFetch checks signal?.aborted in several places (added in #150 for #149); it is only the buffered path that is missing it.

Worth noting the existing harness case abort mid-flight cancels a slow request does not catch this: it aborts 100 ms into /delay/20, where the native cancel wins comfortably. The gap is the window where the response is already buffered.

Library version

1.6.3 (also present on main @ 42ddb6c)

Environment info

Android (Cronet) measured; iOS untested but the buffered path is shared. React Native 0.83.10 (react-native-tvos), new architecture, Hermes. Device: Fire TV stick AFTSS, Android 9, release build.

Steps to reproduce

  1. Issue a fetch(url, { signal }) to a slow endpoint.
  2. Let the response be fully received natively, but abort before the JS promise continuation runs — on a busy JS thread this window is easy to hit; deterministically, block the JS thread for ~1.5 s after starting the fetch, then call controller.abort().
  3. await the promise.
  4. Observe: it resolves with a normal Response instead of rejecting with AbortError, and res.json() parses the body.

Reproducible example repository

Not needed to see the defect — compare the buffered path in nitroFetchRaw with nitroStreamFetch in packages/react-native-nitro-fetch/src/fetch.ts at v1.6.3. A device harness case reproducing it is included in the PR below.

Measurement

React Native app on the device above. Search fans out to 11 servers behind a 2 s debounce; typing s → t → a → r at 3 s intervals supersedes fan-outs still in flight (TanStack Query aborts the dropped queries on observer swap). 48 requests per run, 4 superseded in flight.

  • 4 of 4 completed and were JSON-parsed anyway, with signal.aborted === true at the moment the success path ran. Zero surfaced AbortError.
  • After adding the guard: 0 of 4 parsed; all four surfaced AbortError.
  • Instrumenting both rejection paths separately attributes those four: 3 hit the new guard (Cronet delivered the completed response), and 1 was a genuine Cronet canceled rejection the existing catch already handled. So cancelRequest does sometimes win the race — here it lost 3 times out of 4.
  • ~40 ms of main-thread JSON parse per search session, on a JS thread at 92.5% occupancy.

Suggested fix

One guard after the await, mirroring what the streaming path already does:

   const res: NitroResponseNative = await client.request(req);
+  if (signal?.aborted) {
+    throw createAbortError();
+  }

Opened as #231, with a harness case for the buffered window.

Separately, and not addressed there: NitroResponse has no signal awareness, so an abort landing after the promise settles still leaves res.json() resolving, where a browser would reject.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions