Skip to content

fix(server): settle upstream bodies on the failure paths too - #1539

Merged
lidge-jun merged 3 commits into
devfrom
codex/1419-body-settlement-hardening
Aug 12, 2026
Merged

lidge-jun merged 3 commits into
devfrom
codex/1419-body-settlement-hardening

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Hardening found while investigating #1419. This is not a fix for the native SIGTRAP reported there, and that issue stays open pending the crash frames — see the disposition comment on it.

cancelBodyOnAbort exists because Bun rejects an in-flight internal read when a fetch response body is torn down before our code attaches a reader, and that rejection is orphaned off the awaited path where no caller try/catch can intercept it (src/lib/abort.ts). The guard was installed on the success paths only:

  • src/server/responses/core.ts read the non-2xx error body with .text() before the guard at :3436; the Anthropic terminal-guard continuation did the same.
  • Both web-search executors read the failure body before attaching their guard.
  • /v1/live had no guard at all, passed no signal to readBodyCapped, and released the reader lock without cancelling when a read threw.

Each is the same fetch-resolution-to-reader-attach window the guard was written for, just on the branch nobody guarded.

readBodyCapped now cancels the reader when a read throws. That division matters and is easy to get wrong: body.cancel() from the abort listener throws once a reader holds the lock, so the guard covers the window before attach and the reader covers the window after it. A test pins exactly that, because assuming either one covers both is the mistake this PR is correcting.

An earlier version of my audit claimed no unguarded site existed. It was wrong twice: it checked whether a guard was present in the function rather than on the branch, and it enumerated candidates from files that already imported the helper — a survivorship filter that can only ever find sites which already have one. /v1/live never appeared because it never imported it. The correction is recorded in devlog/_plan/260812_five_bug_fix_campaign/004_audit_wp5_synthesis.md.

Deliberately not in scope, with reasons rather than silence: request-time OAuth token refresh touches credential handling and wants its own change with security review; the CCA image fallback, MiMo JWT bootstrap, and xAI image/video clients have a narrower pre-attach interval; provider quota and model discovery are not turn-body paths. Each is named in the devlog so the list is not lost.

Verification

On a Linux runner (Bun 1.3.14):

  • bun x tsc --noEmit — exit 0
  • bun test tests/cancel-body-on-abort.test.ts — 8 pass, 0 fail
  • bun test tests/cancel-body-on-abort.test.ts tests/abort-race.test.ts tests/server-live.test.ts — 42 pass, 0 fail
  • bun test tests/web-search.test.ts — 51 tests, 0 fail
  • bun run privacy:scan — passed

Note on what the new tests can and cannot assert: a source whose own pull() rejects is errored by the stream machinery, which by spec does not invoke the source's cancel(). So the first test asserts propagation plus a settled stream, and a second test proves the locked-stream limitation directly. Two earlier drafts of these tests asserted behavior the Streams spec does not provide; they were corrected rather than worked around.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Refs #1419

Summary by CodeRabbit

  • Bug Fixes

    • Improved cancellation of interrupted response and streaming requests.
    • Prevented stalled or incomplete response bodies when connections are aborted.
    • Strengthened handling of failed live, web-search, and response requests.
    • Ensured interrupted reads clean up correctly and release resources.
    • Improved reliability when requests fail during response-body processing.
  • Tests

    • Added coverage for interrupted reads, stream cancellation, response-size limits, and normal request completion.

Hardening found while investigating #1419. It is NOT a fix for the native
SIGTRAP reported there, and the issue stays open pending crash frames.

cancelBodyOnAbort exists because Bun rejects an in-flight internal read
when a fetch response body is torn down before our code attaches a
reader, and that rejection is uncatchable by any caller try/catch. The
guard was installed on the success paths only:

- responses/core.ts read the non-2xx error body with .text() before the
  guard at :3436, and the Anthropic continuation did the same;
- both web-search executors read the failure body before their guard;
- /v1/live had no guard at all, passed no signal to readBodyCapped, and
  released the reader lock without cancelling when a read threw.

Each of those is the same fetch-resolution-to-reader-attach window the
guard was written for, just on the branch nobody guarded.

readBodyCapped now cancels the reader when a read throws. A body.cancel()
from the abort listener throws once a reader holds the lock, so the guard
covers the window before attach and the reader covers the window after;
a test pins that division because it is easy to assume one covers both.

An earlier version of my audit claimed no unguarded site existed. That
was wrong twice over: it checked whether a guard was present in the
function rather than on the branch, and it enumerated from files that
already imported the helper, which can only ever find sites that already
have one. 004_audit_wp5_synthesis.md records the correction.

Refs #1419
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a4867be7-efff-4010-a1d2-35b87ee790db

📥 Commits

Reviewing files that changed from the base of the PR and between ceec322 and 2b0bf78.

📒 Files selected for processing (2)
  • src/server/responses/core.ts
  • tests/cancel-body-on-abort.test.ts

📝 Walkthrough

Walkthrough

The change hardens abort-aware response-body handling across live relay, Responses error paths, and web-search executors. It adds stream behavior tests and documents corrected WP5 audit findings and hardening scope.

Changes

Abort-body hardening

Layer / File(s) Summary
Live relay cancellation and stream validation
devlog/_plan/.../004_audit_wp5_synthesis.md, src/server/live.ts, tests/cancel-body-on-abort.test.ts
readBodyCapped cancels readers after read failures and preserves the original error. handleLive attaches an abort guard before body consumption and detaches it afterward. Tests cover lock release, cancellation, normal buffering, byte limits, and source wiring. The audit records corrected findings and scope.
Responses error-body guards
src/server/responses/core.ts
Passthrough, initial, and terminal-continuation error-body reads now use abort guards. Combo failures retain a single bounded body read and clean up upstream abort linkage.
Web-search status handling
src/web-search/anthropic-executor.ts, src/web-search/executor.ts
Both executors attach body guards before HTTP-status branching. Non-success branches detach the guards after reading error bodies.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: review-ready

Suggested reviewers: ingwannu, wibias

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: settling upstream response bodies on failure paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/1419-body-settlement-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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: ad6c4bec0b

ℹ️ 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/server/live.ts
} from "../codex/auth-context";
import { formatCodexProviderForLog } from "../codex/routing";
import { signalWithTimeout } from "../lib/abort";
import { cancelBodyOnAbort, signalWithTimeout } from "../lib/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 Install the live response body guard

When /v1/live fetch resolves and the client aborts before readBodyCapped() attaches its reader, the original Bun teardown race remains: cancelBodyOnAbort is imported here but never invoked anywhere in live.ts, while handleLive still proceeds directly from fetch() through recordOutcome to the body read. Attach the guard immediately after fetch resolution and detach it after body consumption so this request boundary cannot crash the process.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment thread src/server/live.ts Outdated
} finally {
try {
reader.releaseLock();
if (!readFailed) reader.releaseLock();

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 canceling

Whenever reader.read() throws, readFailed makes this branch skip releaseLock(), but reader.cancel() settles the stream without releasing the reader's lock. The body therefore remains permanently locked; the added test's expectation that a second getReader() throws actually demonstrates the leak rather than proving it absent. Release the lock unconditionally in finally after the cancellation attempt.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment on lines +70 to +74
Deferred, with reasons recorded rather than silently dropped: request-time OAuth
refresh (`oauth/*` token endpoints) is a broad surface touching credential
handling and wants its own change with security review; CCA image fallback, MiMo
bootstrap, and the xAI clients have a narrower pre-attach interval; quota and
model discovery are not turn-body paths. Each is named in the follow-up so the

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 pre-disclosure audit out of devlog

Before the deferred fixes have shipped, committing this _plan note publishes the still-unfixed OAuth refresh and other body-attachment candidates, including where to investigate them. The repository explicitly requires such pre-disclosure material to remain in scratch space until the relevant fixes ship; keep this audit in .tmp/ and publish only the completed outcome under _fin/ afterward.

AGENTS.md reference: AGENTS.md:L79-L83

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/live.ts (1)

355-377: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Always release the reader lock after a failed read.

reader.cancel(err) does not release the lock. When the source already errored, cancel() can also reject without invoking the source cancel() hook. The current readFailed guard therefore leaves readBodyCapped rethrowing while the stream remains locked.

In src/server/live.ts:355-377, retain cancellation before rethrowing, but call reader.releaseLock() unconditionally in finally. In tests/cancel-body-on-abort.test.ts:14-37, assert failing.locked is false after rejection. Do not assert that failing.getReader() throws.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/live.ts` around lines 355 - 377, Ensure the readBodyCapped cleanup
in src/server/live.ts:355-377 always calls reader.releaseLock() in finally,
while retaining cancellation before rethrowing failed reads; remove the
readFailed guard from lock release. Update
tests/cancel-body-on-abort.test.ts:14-37 to assert failing.locked is false after
rejection, without asserting that failing.getReader() throws.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/responses/core.ts`:
- Around line 3388-3393: Update the passthrough failure branch around
consumeComboFailure and upstreamResponse.text() to create a cancelBodyOnAbort
guard before either body read, using the upstream response body and signal.
Ensure the guard is detached in a finally block while preserving the existing
failure handling and response behavior.

---

Outside diff comments:
In `@src/server/live.ts`:
- Around line 355-377: Ensure the readBodyCapped cleanup in
src/server/live.ts:355-377 always calls reader.releaseLock() in finally, while
retaining cancellation before rethrowing failed reads; remove the readFailed
guard from lock release. Update tests/cancel-body-on-abort.test.ts:14-37 to
assert failing.locked is false after rejection, without asserting that
failing.getReader() throws.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 48adc9b7-60e2-463a-be79-4c7318ab5adf

📥 Commits

Reviewing files that changed from the base of the PR and between b310d18 and ad6c4be.

📒 Files selected for processing (6)
  • devlog/_plan/260812_five_bug_fix_campaign/004_audit_wp5_synthesis.md
  • src/server/live.ts
  • src/server/responses/core.ts
  • src/web-search/anthropic-executor.ts
  • src/web-search/executor.ts
  • tests/cancel-body-on-abort.test.ts

Comment thread src/server/responses/core.ts Outdated
…branch

Three review findings, all correct.

The /v1/live change imported cancelBodyOnAbort and never called it. The
PR described a guard that was not there, and every existing test still
passed because they exercise readBodyCapped and the helper directly and
cannot see the call site. The guard is now attached before the read and
detached in a finally, and a wiring test asserts that ordering — crude,
but it is the thing that was missing.

readBodyCapped held the reader lock after a failed read. reader.cancel()
does not release the lock, so suppressing releaseLock() left the stream
permanently locked for any later consumer. It now always releases, and
the test asserts a second reader can be acquired and observes the
original error instead of asserting the lock is retained.

The native Responses passthrough had the same unguarded non-2xx branch as
the translated path — consumeComboFailure or .text() before any guard. It
was neither fixed nor listed among the deliberate deferrals, so it is
fixed here.

Refs #1419

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/cancel-body-on-abort.test.ts`:
- Around line 85-91: Strengthen the passthrough guard test around the existing
source-order assertions so cancelBodyOnAbort appears before both
consumeComboFailure and upstreamResponse.text(). Keep checking that
detachPassthroughErrorGuard exists, and compare symbol positions to fail if
either body consumption occurs before the guard is attached.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 64254b74-cd2c-41ad-893b-0c9a1c81f7c4

📥 Commits

Reviewing files that changed from the base of the PR and between ad6c4be and ceec322.

📒 Files selected for processing (3)
  • src/server/live.ts
  • src/server/responses/core.ts
  • tests/cancel-body-on-abort.test.ts

Comment on lines +85 to +91
test("the passthrough error branch attaches the body guard before consuming it", async () => {
const source = await Bun.file(new URL("../src/server/responses/core.ts", import.meta.url)).text();

const guardAt = source.indexOf("cancelBodyOnAbort(upstreamResponse.body, upstream.signal)");
expect(guardAt).toBeGreaterThan(-1);
expect(source).toContain("detachPassthroughErrorGuard");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert passthrough guard ordering.

At Lines 85-91, the test only checks that the guard and detacher exist. It does not verify that the guard precedes consumeComboFailure and upstreamResponse.text().

If a later change moves the guard after either body read, this test still passes and the abort race returns.

Proposed test fix
     const guardAt = source.indexOf("cancelBodyOnAbort(upstreamResponse.body, upstream.signal)");
+    const comboReadAt = source.indexOf("const failure = await consumeComboFailure(upstreamResponse");
+    const textReadAt = source.indexOf("const errorText = await upstreamResponse.text()");
     expect(guardAt).toBeGreaterThan(-1);
+    expect(comboReadAt).toBeGreaterThan(-1);
+    expect(textReadAt).toBeGreaterThan(-1);
+    expect(guardAt).toBeLessThan(comboReadAt);
+    expect(guardAt).toBeLessThan(textReadAt);
     expect(source).toContain("detachPassthroughErrorGuard");

As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("the passthrough error branch attaches the body guard before consuming it", async () => {
const source = await Bun.file(new URL("../src/server/responses/core.ts", import.meta.url)).text();
const guardAt = source.indexOf("cancelBodyOnAbort(upstreamResponse.body, upstream.signal)");
expect(guardAt).toBeGreaterThan(-1);
expect(source).toContain("detachPassthroughErrorGuard");
});
test("the passthrough error branch attaches the body guard before consuming it", async () => {
const source = await Bun.file(new URL("../src/server/responses/core.ts", import.meta.url)).text();
const guardAt = source.indexOf("cancelBodyOnAbort(upstreamResponse.body, upstream.signal)");
const comboReadAt = source.indexOf("const failure = await consumeComboFailure(upstreamResponse");
const textReadAt = source.indexOf("const errorText = await upstreamResponse.text()");
expect(guardAt).toBeGreaterThan(-1);
expect(comboReadAt).toBeGreaterThan(-1);
expect(textReadAt).toBeGreaterThan(-1);
expect(guardAt).toBeLessThan(comboReadAt);
expect(guardAt).toBeLessThan(textReadAt);
expect(source).toContain("detachPassthroughErrorGuard");
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cancel-body-on-abort.test.ts` around lines 85 - 91, Strengthen the
passthrough guard test around the existing source-order assertions so
cancelBodyOnAbort appears before both consumeComboFailure and
upstreamResponse.text(). Keep checking that detachPassthroughErrorGuard exists,
and compare symbol positions to fail if either body consumption occurs before
the guard is attached.

Source: Path instructions

CI caught this: 'captures passthrough failed usage from its original
bounded body exactly once' went red on both combo branches.

The test is right and the guard was wrong. consumeComboFailure ->
readBoundedResponseBody reads response.body itself and threads the abort
signal through its own read, so it already owns settlement on that path.
Attaching cancelBodyOnAbort first added a SECOND .body getter access,
which is exactly what that test pins against — the combo contract is that
the body is touched once.

Both combo branches now go straight to consumeComboFailure. The plain
.text() paths keep their guard, because there the reader only attaches
when .text() runs and nothing else settles the body.

A test records the distinction so the guard does not get 'helpfully'
added back.

Refs #1419
@lidge-jun

Copy link
Copy Markdown
Owner Author

Full-suite result at this head on the Linux runner (Bun 1.3.14):

11334 pass
0 fail
Ran 11345 tests across 698 files. [405.95s]
EXIT=0

All four Linux CI shards are green, including the test 1/4 failure that this head fixed — CI caught a real defect in an earlier revision, where the guard I added to the combo failure branches broke the "body getter touched exactly once" contract those branches deliberately keep. The combo branches are now left unguarded on purpose, because readBoundedResponseBody already owns settlement there, and a test records the distinction.

@lidge-jun
lidge-jun merged commit d03755e into dev Aug 12, 2026
27 checks passed
@lidge-jun
lidge-jun deleted the codex/1419-body-settlement-hardening branch August 12, 2026 14:14
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…un#1539)

* fix(server): settle upstream bodies on the failure paths too

Hardening found while investigating lidge-jun#1419. It is NOT a fix for the native
SIGTRAP reported there, and the issue stays open pending crash frames.

cancelBodyOnAbort exists because Bun rejects an in-flight internal read
when a fetch response body is torn down before our code attaches a
reader, and that rejection is uncatchable by any caller try/catch. The
guard was installed on the success paths only:

- responses/core.ts read the non-2xx error body with .text() before the
  guard at :3436, and the Anthropic continuation did the same;
- both web-search executors read the failure body before their guard;
- /v1/live had no guard at all, passed no signal to readBodyCapped, and
  released the reader lock without cancelling when a read threw.

Each of those is the same fetch-resolution-to-reader-attach window the
guard was written for, just on the branch nobody guarded.

readBodyCapped now cancels the reader when a read throws. A body.cancel()
from the abort listener throws once a reader holds the lock, so the guard
covers the window before attach and the reader covers the window after;
a test pins that division because it is easy to assume one covers both.

An earlier version of my audit claimed no unguarded site existed. That
was wrong twice over: it checked whether a guard was present in the
function rather than on the branch, and it enumerated from files that
already imported the helper, which can only ever find sites that already
have one. 004_audit_wp5_synthesis.md records the correction.

Refs lidge-jun#1419

* fix(server): actually wire the live guard, and cover the passthrough branch

Three review findings, all correct.

The /v1/live change imported cancelBodyOnAbort and never called it. The
PR described a guard that was not there, and every existing test still
passed because they exercise readBodyCapped and the helper directly and
cannot see the call site. The guard is now attached before the read and
detached in a finally, and a wiring test asserts that ordering — crude,
but it is the thing that was missing.

readBodyCapped held the reader lock after a failed read. reader.cancel()
does not release the lock, so suppressing releaseLock() left the stream
permanently locked for any later consumer. It now always releases, and
the test asserts a second reader can be acquired and observes the
original error instead of asserting the lock is retained.

The native Responses passthrough had the same unguarded non-2xx branch as
the translated path — consumeComboFailure or .text() before any guard. It
was neither fixed nor listed among the deliberate deferrals, so it is
fixed here.

Refs lidge-jun#1419

* fix(responses): leave the combo failure branches unguarded, deliberately

CI caught this: 'captures passthrough failed usage from its original
bounded body exactly once' went red on both combo branches.

The test is right and the guard was wrong. consumeComboFailure ->
readBoundedResponseBody reads response.body itself and threads the abort
signal through its own read, so it already owns settlement on that path.
Attaching cancelBodyOnAbort first added a SECOND .body getter access,
which is exactly what that test pins against — the combo contract is that
the body is touched once.

Both combo branches now go straight to consumeComboFailure. The plain
.text() paths keep their guard, because there the reader only attaches
when .text() runs and nothing else settles the body.

A test records the distinction so the guard does not get 'helpfully'
added back.

Refs lidge-jun#1419
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant