Skip to content

fix: extend abort signal coverage through response body consumption - #12

Merged
solomonneas merged 3 commits into
masterfrom
copilot/regression-response-body-stalls
Aug 15, 2026
Merged

fix: extend abort signal coverage through response body consumption#12
solomonneas merged 3 commits into
masterfrom
copilot/regression-response-body-stalls

Conversation

Copilot AI commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

fetchWithTimeout() in effect-operator-kit clears its abort timer the moment fetch() resolves with response headers. response.text() then runs unguarded — a server that sends headers immediately but stalls the body stream bypasses timeoutMs entirely.

Changes

  • fetchWithPlainHeaders (both clients) — converted from a thin header-normalization shim to a full round-trip wrapper: awaits headers, then races response.text() against the same AbortSignal passed in via init. If the timer fires mid-body, the body-read Promise rejects with AbortError immediately, which the existing TransportError path surfaces as AdGuardUnreachableError / AdGuardSyncUnreachableError.
// Body read now races the signal that fetchWithTimeout already manages
const bodyText = await new Promise<string>((resolve, reject) => {
  const onAbort = () =>
    reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
  signal?.addEventListener("abort", onAbort, { once: true });
  response.text().then(
    (text) => { signal?.removeEventListener("abort", onAbort); resolve(text); },
    (err)  => { signal?.removeEventListener("abort", onAbort); reject(err); },
  );
});
return new Response(bodyText, { status: response.status, headers: response.headers });
  • Regression tests — added to client.test.ts and sync-client.test.ts: stub fetch to resolve headers instantly but never close the body stream; assert that timeoutMs causes the expected unreachable error rather than hanging indefinitely.

The fetchWithTimeout() helper in effect-operator-kit clears the timer
as soon as fetch() resolves with response headers, leaving response.text()
uncovered. A stalled body stream therefore bypasses the configured timeout.

Both AdGuardClient and AdGuardSyncClient now use a fetchWithPlainHeaders
wrapper that:
1. Awaits fetch() headers as before.
2. Races response.text() against the same AbortSignal that fetchWithTimeout
   created. When the timer fires and aborts the controller, the body-read
   Promise rejects with AbortError before text() completes.
3. Returns a synthetic Response with the pre-read body text, so the rest
   of the effect-operator-kit pipeline is unchanged.

Adds regression tests for the stalled-body scenario in client.test.ts
and sync-client.test.ts. All 178 tests pass, typecheck clean, build ok.

Co-authored-by: solomonneas <41877493+solomonneas@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix regression causing response body to stall beyond request timeout fix: extend abort signal coverage through response body consumption Aug 15, 2026
Copilot AI requested a review from solomonneas August 15, 2026 13:50
Rejecting the body promise on abort left response.text() reading in the
background, so the stalled body the timeout fired on kept consuming the
connection. Cancelling response.body is what actually releases it.

Also carries statusText through the reconstructed Response; callers
surface it in error messages and it was being dropped.

Co-Authored-By: Claude <noreply@anthropic.com>
@solomonneas
solomonneas marked this pull request as ready for review August 15, 2026 19:26
Copilot AI lite review requested due to automatic review settings August 15, 2026 19:26
@solomonneas
solomonneas merged commit 250c437 into master Aug 15, 2026
@solomonneas
solomonneas deleted the copilot/regression-response-body-stalls branch August 15, 2026 19:26

Copilot AI 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.

Pull request overview

This PR fixes a timeout regression where request timeouts stopped applying once fetch() returned response headers, allowing stalled response bodies to hang indefinitely. It does so by ensuring the fetch wrapper does not resolve until the response body has been consumed (or the shared abort signal fires), and adds regression coverage for the “headers arrive, body stalls” scenario.

Changes:

  • Update both clients’ fetchWithPlainHeaders to keep the upstream abort signal “in play” through response.text() (including canceling the body stream on abort).
  • Add regression tests for both clients where fetch resolves headers immediately but the response body never completes.
  • Add .graphtrail/ to .gitignore.

Note: I did not run ./scripts/verify in this review environment (no verification results to report).

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/adguard-client.ts Wraps fetch to extend abort/timeout coverage through response body consumption.
src/adguard-sync-client.ts Same timeout/body-consumption protection for the Sync client transport.
tests/client.test.ts Adds regression test for “headers resolved, body stalls” timeout behavior.
tests/sync-client.test.ts Adds the analogous stalled-body timeout regression test for the Sync client.
.gitignore Ignores .graphtrail/.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +175 to +176
const abortError = (): Error =>
Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
Comment thread src/adguard-client.ts
Comment on lines +172 to +173
const abortError = (): Error =>
Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
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.

regression: response-body stalls bypass request timeout

3 participants