Skip to content

AbortSignal: emit write barrier when storing abort reason - #32747

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/3679b774/abortsignal-reason-write-barrier
Jun 26, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/3679b774/abortsignal-reason-write-barrier

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

AbortSignal::markAborted stored m_reason via JSValueInWrappedObject::setWeakly() without emitting a write barrier (the old code had a FIXME admitting it). JSAbortController::visitChildren visits signal().reason() but had no visitOutputConstraints, so once the concurrent collector had already scanned a controller, a later controller.abort(err) on the mutator thread was invisible to the GC: the controller was never rescanned and the Error passed as the reason was left unmarked.

JSC's GC verifier catches this directly:

GC Verifier: ERROR cell 0x7bfb390721c8 was not marked
  Object 0x7bfb390721c8 ... (Structure: Error, {message})
  In the real GC, this cell was NOT marked.
  In the verifier GC, it was visited via:
    0x7bfb393d1a98 (Structure: AbortController)
ASSERTION FAILED: this->isMarked(cell)   JavaScriptCore/heap/Heap.cpp:3469 (Heap::verifyGC)

Without the verifier the observable effect is that signal.reason silently becomes undefined while the controller is still reachable (the Weak<> in JSValueInWrappedObject is cleared). Any downstream consumer that holds the controller then reads a freed-and-possibly-reused cell.

Reproduction

BUN_JSC_verifyGC=1 BUN_JSC_collectContinuously=1 BUN_JSC_numberOfGCMarkers=8 bun -e '
  const controllers = [];
  for (let iter = 0; iter < 500; iter++) {
    const c = new AbortController();
    controllers.push(c);
    const garbage = [];
    for (let j = 0; j < 50; j++) garbage.push({ a: j, b: new Array(10).fill(j) });
    c.abort(new Error("reason-" + iter));
  }
  let lost = 0;
  for (const c of controllers) if (!(c.signal.reason instanceof Error)) lost++;
  console.log("PASS lost=" + lost);
'

On current main this either asserts inside Heap::verifyGC or prints lost > 0.

Fix

Match upstream WebKit (https://bugs.webkit.org/show_bug.cgi?id=236353):

  • AbortSignal::markAborted / AbortSignal::jsReason: store m_reason via set(vm, wrapper(), reason) so a write barrier fires on the JSAbortSignal wrapper.
  • JSAbortController: add visitOutputConstraints / visitAdditionalChildrenInGCThread so its subspace is registered as an output-constraint space and the DOMGCOutputConstraint re-visits the controller after the mutator has run. This covers the case where no JSAbortSignal wrapper exists yet (only the controller).
  • JSValueInWrappedObject::set: null-check owner before the barrier, matching upstream, since wrapper() is null before a JS wrapper is created.

Verification

  • bun bd test test/js/web/abort/ : 13 pass / 0 fail
  • New test fails 5/5 on an unfixed bun bd build (verifier asserts) and passes 5/5 with the fix.

AbortSignal::markAborted and AbortSignal::jsReason stored m_reason via
JSValueInWrappedObject::setWeakly without emitting a write barrier.
JSAbortController::visitChildren visits signal().reason() but had no
visitOutputConstraints, so once the concurrent collector had scanned a
controller, a later controller.abort(err) on the mutator thread was
invisible to the GC: the controller was never rescanned and the reason
Error was left unmarked. BUN_JSC_verifyGC=1 catches this as
'cell ... was not marked' and without the verifier the reason is
collected while the controller is still reachable, so signal.reason
silently becomes undefined (and any downstream consumer of it sees a
stale or freed cell).

Match upstream WebKit (https://bugs.webkit.org/show_bug.cgi?id=236353):
store m_reason via set() so a write barrier fires on the AbortSignal
wrapper, and give JSAbortController visitOutputConstraints so the
DOMGCOutputConstraint re-visits it after the mutator has run. Also null
check the owner in JSValueInWrappedObject::set, since wrapper() is null
before the JS wrapper is created.
@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:48 AM PT - Jun 26th, 2026

❌ @Jarred-Sumner, your commit 1598cf2 has 3 failures in Build #64914 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32747

That installs a local version of the PR into your bun-32747 executable, so you can run:

bun-32747 --bun

@coderabbitai

coderabbitai Bot commented Jun 26, 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

Run ID: bf524753-ef8e-4e8e-9429-3404e3fe7d3b

📥 Commits

Reviewing files that changed from the base of the PR and between ea1bc5e and 007db39.

📒 Files selected for processing (1)
  • test/js/web/abort/abort-controller-gc-reason.test.ts

Walkthrough

AbortSignal now stores abort reasons with context-aware GC barriers, JSAbortController adds GC visitation hooks for signal.reason, and the abort-controller GC test exercises concurrent verification.

Changes

AbortController GC reason handling

Layer / File(s) Summary
Abort reason barriers
src/jsc/bindings/webcore/AbortSignal.cpp, src/jsc/bindings/webcore/JSValueInWrappedObject.h
AbortSignal now stores reasons with a VM-aware barrier when a context exists, and JSValueInWrappedObject::set(...) only writes the barrier when owner is non-null.
JSAbortController GC visitation
src/jsc/bindings/webcore/JSAbortController.h, src/jsc/bindings/webcore/JSAbortController.cpp
JSAbortController declares and implements additional GC-thread visitation for wrapped().signal().reason(), and forwards that visitation from visitChildrenImpl and visitOutputConstraints.
GC regression test
test/js/web/abort/abort-controller-gc-reason.test.ts
A Windows-skipped regression test runs GC verification in a child Bun process, aborts many controllers with Error, and checks that signal.reason stays marked.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main fix to AbortSignal's abort-reason write barrier.
Description check ✅ Passed The description covers the problem, fix, reproduction, and verification, so it is mostly complete despite using different headings than the template.
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.

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

@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 `@test/js/web/abort/abort-controller-gc-reason.test.ts`:
- Around line 13-18: Shorten the explanatory comment in the abort-controller GC
test to fit the 3-line comment limit. Collapse the GC-verifier rationale into a
tighter summary, or move the extra background about setWeakly(),
visitOutputConstraints, and the scanned-after-abort case into the PR description
instead. Keep the remaining comment focused on the key reason the test exists
and reference the abort reason/Error marking behavior in
abort-controller-gc-reason.test.ts.
🪄 Autofix (Beta)

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

Run ID: a9f63604-df27-45de-81ae-285e4d6a8edd

📥 Commits

Reviewing files that changed from the base of the PR and between 0589548 and ea1bc5e.

📒 Files selected for processing (5)
  • src/jsc/bindings/webcore/AbortSignal.cpp
  • src/jsc/bindings/webcore/JSAbortController.cpp
  • src/jsc/bindings/webcore/JSAbortController.h
  • src/jsc/bindings/webcore/JSValueInWrappedObject.h
  • test/js/web/abort/abort-controller-gc-reason.test.ts

Comment thread test/js/web/abort/abort-controller-gc-reason.test.ts Outdated

@claude claude 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.

I didn't find any issues, but this touches JSC concurrent-GC write-barrier / output-constraint plumbing (and JSValueInWrappedObject explicitly says changes need a JavaScriptCore reviewer), so this should get a human look.

Extended reasoning...

Overview

This PR fixes a missing GC write barrier when storing AbortSignal::m_reason. It changes AbortSignal::markAborted / jsReason to call JSValueInWrappedObject::set(vm, wrapper(), value) instead of setWeakly, adds visitOutputConstraints / visitAdditionalChildrenInGCThread to JSAbortController so its IsoSubspace is registered into outputConstraintSpaces() (verified in BunClientData.h:213-216), null-checks owner in JSValueInWrappedObject::set, and adds a verifyGC/collectContinuously regression test.

Security risks

None — this is GC marking correctness, no parsing, auth, or external input handling. The risk profile here is memory-safety / use-after-free if the barrier logic is wrong in either direction, which is a stability concern rather than an attack surface.

Level of scrutiny

High. JSValueInWrappedObject.h itself carries the comment "This class includes a lot of GC related subtle things… Any changes on this class must be reviewed by JavaScriptCore reviewers too." The change follows the exact same pattern as existing classes (JSMessagePort, JSCustomEvent, JSEventEmitter) and matches the upstream WebKit fix referenced in the description, so it looks correct, but concurrent-GC barrier semantics are subtle enough that a human familiar with Bun's JSC integration should sign off.

Other factors

The header/cpp additions mirror established codegen patterns; subspaceForImpl<T> automatically picks up the new visitOutputConstraints to enroll the subspace in the DOMGCOutputConstraint set. The new test follows repo conventions (combined toEqual assertion, 120s timeout for collectContinuously, Windows skip). CodeRabbit's only nit (comment length) was addressed in 007db39. CI shows build-rust failures across all Linux/FreeBSD/Android shards, which look like an unrelated infra/toolchain issue rather than something caused by these C++ changes.

@claude claude 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.

I didn't find any issues, but this touches concurrent-GC write barriers and JSValueInWrappedObject (which is explicitly flagged as "must be reviewed by JavaScriptCore reviewers"), so it warrants a human look.

Extended reasoning...

Overview

This PR fixes a missing GC write barrier when storing AbortSignal::m_reason. It changes four C++ files in src/jsc/bindings/webcore/ (AbortSignal.cpp, JSAbortController.{h,cpp}, JSValueInWrappedObject.h) and adds a regression test. markAborted/jsReason now call m_reason.set(vm, wrapper(), reason) instead of setWeakly(), JSAbortController gains visitOutputConstraints/visitAdditionalChildrenInGCThread so its IsoSubspace is registered in outputConstraintSpaces() (verified in BunClientData.h:213-216), and JSValueInWrappedObject::set null-checks owner before the barrier.

Security risks

This is memory-safety-adjacent: the bug being fixed is a concurrent-GC marking miss that can lead to a freed cell being read back via signal.reason. The fix itself follows the upstream WebKit pattern (https://bugs.webkit.org/show_bug.cgi?id=236353) and looks correct — the write barrier targets the JSAbortSignal wrapper, the output-constraint registration covers the controller-only case, and the owner null-check degrades to the prior setWeakly behavior when no wrapper exists yet. I don't see a new vulnerability introduced, but the consequence of any subtle error here (missed mark, premature collection, UAF) is severe.

Level of scrutiny

High. JSValueInWrappedObject.h carries an in-source warning: "This class includes a lot of GC related subtle things, and changing this class easily causes GC crashes. Any changes on this class must be reviewed by JavaScriptCore reviewers too." Concurrent-GC write-barrier and output-constraint changes are exactly the class of edit that benefits from a second pair of expert eyes, even when the diff looks right and mirrors upstream.

Other factors

  • The new visitOutputConstraints follows the same pattern as JSMessageEvent, JSErrorEvent, JSEventTarget, etc. in the same directory, and subspaceForImpl will pick it up automatically via the function-pointer comparison.
  • A targeted regression test is included (verifyGC + collectContinuously + parallel markers) and the PR description reports it fails 5/5 pre-fix and passes 5/5 post-fix.
  • CI shows build-rust failures across all Linux/FreeBSD/Android shards on the latest run; these look like unrelated infrastructure breakage (this PR touches no Rust), but the build isn't green yet.
  • CodeRabbit's only comment (3-line comment limit) was addressed in 007db39.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 64914 is red on three lanes, none related to this diff. For reference:

  • darwin-26-aarch64 test-bun: buildkite-agent artifact download timed out after 120s (infra). The automatic retry passed.
  • darwin-14-aarch64 test-bun: bun-serve-file.test.ts (whole-file timeout) and fetch-file-upload.test.ts (uploads roundtrip with sendfile() hit the 10s per-test timeout). bun-serve-file.test.ts also failed on main build 64895, so this is pre-existing flake on the slow mac lane.
  • alpine-3.23-aarch64 test-bun: test/regression/issue/26030.test.ts could not start: Failed to start service mysql_plain ... application not healthy after 1m0s (Docker container never became healthy). The same MySQL container health-check failure hit a different lane on the previous run of this PR.

Nothing in this PR touches HTTP serving, sendfile, MySQL, or the install proxy. All 23 debian-13-x64-asan test shards passed, which is the lane that exercises the new GC regression test. I already pushed one ci: retrigger earlier, so I will not push another. The diff is ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit b4e9605 into main Jun 26, 2026
74 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/3679b774/abortsignal-reason-write-barrier branch June 26, 2026 18:50
pierrecabriere added a commit to appstrate/appstrate that referenced this pull request Jul 27, 2026
Searched before reporting: this is oven-sh/bun#34636, closed as completed,
fixed by oven-sh/bun#32747 — `AbortSignal::markAborted` stored the reason
without a GC write barrier, so a concurrent collector that had already
scanned the signal wrapper could take the reason while the signal stayed
alive. No issue filed; it would have duplicated a fixed one.

The fix landed after v1.3.14 and is not in the pinned toolchain, so the
accessor stays — with the condition for deleting it written down instead of
left for someone to rediscover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pierrecabriere added a commit to appstrate/appstrate that referenced this pull request Jul 27, 2026
…-silent closure (#982)

Fixes the failure modes behind one audited chat session: **7 runs,
$4.68, and nothing delivered to the user.** A `report.html` was produced
two minutes after the turn was killed and announced nowhere.

Two defects of modelling explain almost all of it:

1. **A chat turn was treated as a synchronous transaction** while
orchestrating jobs longer than itself.
2. **A run was treated as returning a value** (results transit the LLM's
context) **when it produces an artifact** (results should transit by
reference).

## What changed

| Area | Change |
| --- | --- |
| Turn closure | A turn killed by its deadline no longer ends in
silence: a real persisted text part + a new `finishReason: "deadline"`.
Enforced on **both** engines. |
| Step cap | `CHAT_MAX_STEPS` was reported and never applied on the Pi
engine. It now stops the tool loop and spends the last step on one
tool-less answer. |
| Turn budget | `run_and_wait` derives its wait from an absolute turn
deadline instead of silently taking a 30-minute default three times
longer than the turn. A launch with too little budget left is refused —
nothing created, nothing spent. |
| Fan-in | `context_documents` mounts previous runs' `document://` URIs
by reference. A prompt naming a document the input does not mount is
still refused (400) — explicitly, not repaired. |
| Prompt | Fan-out/fan-in contract, effort ceilings,
deliver-before-continuing, and `integration_not_active` recovery. **+7
net lines** (`+9 / -2`). |
| Integrations | An `integration_not_active` 412 is routed to
`activateIntegration` instead of a retry — the platform's RBAC decides
who may clear it. |
| Runner | An agent that ends without calling `output` gets exactly one
corrective turn while its context is still alive. |
| Orphan runs | A run finishing after its turn posts a notice into the
chat session. |

## Notes for the reviewer

**Auto-activation on connect was built, then removed** (`92be77616`
reverts `88df5ee54`). It first shipped with a privilege escalation — a
plain member (holding `integrations:connect`) could install an
integration application-wide (admin-only `integrations:install`) — fixed
by threading the capability as a fail-closed claim across the two
session-less connect surfaces.

That fix was correct but exposed the real cost. The threading is
intrinsic, not accidental: the decision point is authenticated (the
`/connect/*` request) while the action point is not (the OAuth callback
is stateless; the hosted Connect portal has no org member at all), so
any auto-activation must carry the capability across that gap — and
re-deriving it there *was* the escalation. 278 lines of production code
across 8 files plus a signed token claim, to save one API call.

Its reach was also narrower than it looked: env-backed SYSTEM
integrations are already active without an install row, so it only ever
covered org-installed ones. And the obvious cheaper alternative — refuse
the connection until the integration is active — is not viable, because
`integrations:connect` is a member right while activation is admin-only;
it would make member self-connect impossible until an admin acted first.

What replaces it is strictly better: `activateIntegration` is already in
the platform's operation surface, so the prompt sends the model there
and **the platform's own RBAC decides** — an admin clears the 412 in one
step, a member gets a 403 and is told to ask one. Same one-step UX where
the right exists, zero capability pre-computed or transported, and the
escalation class is structurally gone rather than guarded.

**An adversarial security pass found three more.** Two of them turned
out to be arguments against the code that produced them, and that code
is now gone (see below): an unbounded ACL-probe fan-out in the
prompt-URI repair path (~8 300 concurrent queries from one request), and
a run-result spill locatable by name and therefore impersonable by an
agent. The third — agent-chosen document names rendered unquoted into a
persisted `assistant` message — is fixed and stays.

**Two claims in this branch were falsified during review and corrected
rather than re-documented.** Namespacing the spill document's name did
not prevent collision — the dedup key is `(run_id, sha256, name)`, so a
decoy and the real spill would coexist. And the budget note advertised a
launch threshold the gate does not apply.

**Two features were built, then removed** (`f6ec12465`), on the same
test their author should have applied first: what does this cost, and
what does the evidence say it buys?

- **Auto-repair of `document://` URIs pasted into a prompt.** It turned
inert prompt text into a mount primitive — its own comment said "prompt
injection can now cause a mount" — and needed that ACL fan-out bounded.
What it replaced was a recoverable 400 that already existed and is still
wired. The reason a model pasted URIs into prose is precisely what
`context_documents` plus the prompt paragraph fix, so the branch was
paying twice: shipping the instruction that prevents the mistake AND the
machinery that papers over it.
- **The run-result spill document.** The 32 KB truncation stays —
nothing else bounds how much of `runs.result` reaches a chat context.
The COPY goes: the full payload is already durable in `runs.result` and
already readable through `getRun`, so a dedicated `agent_output`
duplicate bought only a `document://` handle, and locating it by name is
what opened the impersonation hole that then needed a publish-boundary
refusal to close.

Removing the spill also fixed a defect in it: truncation was conditional
on the spill existing, and the spill was best-effort (org quota, per-run
cap) — so the guard disarmed itself exactly when a result was large
enough to matter. It is unconditional now, with a test for the
run-published-nothing case the old path skipped.

Accepted regression: a >32 KB result no longer produces a downloadable
chip. Deliverables belong in the run's `outputs/`, which the prompt
already mandates.

**One diagnostic in the originating audit was wrong.** `stepCount: 20`
vs `maxSteps: 16` was not evidence of 20 model calls: the counter
counted messages, and pi emits `message_start` for the user prompt and
every tool result. The turn ran out of time, not steps. The counter is
now split.

**Two SDK behaviours forced the design and are worth knowing:**
- `streamText` never publishes a `finish` part on abort — it enqueues
`{type:"abort"}` and closes. Metadata alone could not carry the outcome,
so the notice and the finish chunk are injected in-band via a
`TransformStream.flush()`.
- `signal.reason` can read back `undefined` while `signal.aborted` stays
`true` in the pinned Bun. Reading it later would have silently
downgraded the deadline to an untagged stop — reintroducing the exact
silence this PR removes. This is
[oven-sh/bun#34636](oven-sh/bun#34636),
already fixed upstream by
[#32747](oven-sh/bun#32747) (a missing GC write
barrier in `AbortSignal::markAborted`) in a release later than the one
pinned here. The trigger is simply that nothing else holds the reason
object — not the timer, not a forced GC; a synchronous `abort(new
Error())` loses it too. The workaround carries a removal condition in
the code.

## Deliberately not built

No retry for a notice missed when a newer turn happens to be generating
at the instant an orphan finalizes — that needs a queue, a worker or a
table. The deliverable stays reachable from the run page and the
documents gallery; the limit is documented in the module header.

The 32 KB result truncation is a **context safety net, not the fan-in
fix** — the audited results were 9-11.6 KB and would not have tripped
it. It earns its place by bounding an otherwise unbounded surface, not
by fixing the incident. The fan-in fix is `context_documents` plus the
prompt's reference rule.

## Verification

- `bun run check` — 33/33 tasks, OpenAPI all checks passed,
type-coverage 99.04%
- `bun test` — **9049 pass, 46 skip, 0 fail** across 693 files
- CI **Integration tests** and **E2E tests** both run on this PR (labels
`integration` + `e2e`), not just post-merge

### Live smoke test (done)

Run against a local instance on this branch, real models, both engines —
the gap the previous revision of this section flagged as advisable
before merge.

| Path | Result |
| --- | --- |
| ai-sdk turn with tools (deepseek) | `finishReason: "stop"`,
`stepCount: 3`, correct answer from real MCP tool output |
| subscription turn with tools (Pi / claude-code) | `finishReason:
"stop"`, `stepCount: 2`, `lastToolName: invoke_operation` |
| A5 budget note — ai-sdk | model quotes it verbatim: `10m00s left …
step 0/16 … needs at least 2m15s` |
| A5 budget note — subscription | rides the tool result, `step 1/16` —
the split counter reads correctly |
| **Turn deadline (the blocker fix)** | deadline forced to 30 s: turn
cut at 33 s, `finishReason: "deadline"`, notice injected, **both
persisted in `chat_messages`** |
| `run_and_wait` refusal | non-error payload, model relays it, **zero
`runs` rows created** |

**The smoke test found a real bug**, fixed in 56eb8c1: the refusal
message carried the same wrong threshold this PR had already fixed in
the budget note, and rendered the post-reserve run budget as the turn's
remaining time — contradicting `remaining_ms` in its own payload. The
arithmetic now lives in one exported constant both texts read.

Two observations, deliberately not changed: the subscription model
treated the `[turn budget]` line as a possible prompt injection before
complying (it rides an unlabelled tool result — prompt design, not a
defect); and `turnDeadlineNoticeText` renders "1 minutes" via
`Math.max(1, …)`, unreachable at the 10-minute production constant.

Still not covered by automated tests: the live subscription path (needs
the Pi SDK, a real token and a model). The step cap's `terminate`
mechanism remains verified against the SDK source and a fake session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants