Skip to content

fix(gc): scope inline-arena resync to the Eden arena (#1824) - #2134

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-1824-async-frame-resume
May 28, 2026
Merged

proggeramlug merged 1 commit into
mainfrom
worktree-fix-1824-async-frame-resume

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Fixes #1824.

Root cause — allocator-state corruption, not GC

The reporter's symptom — a garbage "function pointer" in the resumed async-step continuation, reproducible with no GC cycle and surviving every GC escape hatch (PERRY_GEN_GC=0, PERRY_GEN_GC_EVACUATE=0, PERRY_WRITE_BARRIERS=0) — is not a GC/rooting bug and not codegen spill/restore. It is corruption of the codegen inline bump-allocator's INLINE_STATE by a non-Eden allocation:

  1. Arena::alloc's block-reuse forward-scan calls resync_inline_to_current, which mirrors the thread-global INLINE_STATE.
  2. INLINE_STATE is only meaningful for the general nursery-Eden arena, but the same Arena::alloc body backs OLD_ARENA / survivors / longlived.
  3. A large await result (e.g. await response.json() of a big payload) is born in the old-gen via arena_alloc_gc's large-object path. When that old-gen allocation forward-scans to reuse an earlier old-gen block, resync_inline_to_current repoints INLINE_STATE at a non-Eden block.
  4. The next general-Eden arena_alloc then writes that foreign block's offset into the live Eden block, rewinding the Eden bump pointer.
  5. A fresh string allocation lands on top of a still-live, suspended async-step closure. When the microtask runner later resumes the continuation, its overwritten func_ptr (now string bytes) is jumped to → SIGSEGV.

This exactly reproduces the report: full_gc=0/minor_gc=0 (no collection), ASCII-bytes-as-pointer in lldb (e.g. 0x343030302f303036 = "600/0004" from the JSON URL strings), and the crash surviving every GC mode.

Fix

resync_inline_to_current no-ops unless self.space == HeapSpace::NurseryEden. The Eden forward-scan path is unchanged; only the non-Eden arenas stop clobbering INLINE_STATE. 16 lines including the explanatory comment.

Validation

  • Built a self-contained repro (local HTTP server + fetch/json in a loop feeding a long-lived accumulator + a retained BIG) that SIGSEGV'd deterministically with the reported signature.
  • After the fix, full mark-sweep (PERRY_GEN_GC=0 — the mode the reporter tested where the crash persisted) runs the repro 3/3 clean to "ALL DONE".
  • Existing arena:: (31) and gc:: (289) unit tests still green.
  • New regression test old_arena_block_reuse_does_not_repoint_eden_inline_state exercises an OLD_ARENA block-reuse forward-scan and asserts the Eden INLINE_STATE is left intact. Fails before the fix, passes after.
  • cargo fmt --all -- --check clean.

Follow-up I noticed while verifying (not this PR's scope)

The aggressive repro (8000-element retained allocation + 5000-element JSON payloads) still SIGSEGVs under default generational GC only after this fix — a garbage promise pointer 0x65 ('e' string byte) in js_promise_reject from the microtask runner, requiring ≥2 copying-GC cycles. It does not reproduce under PERRY_GEN_GC=0 and not with a smaller payload. So that is a separate copying-GC promise-chain rooting issue under heavy large-object pressure (#1597 family), distinct from the reported crash. I can file it as its own issue if useful.

`Arena::alloc`'s block-reuse forward-scan calls `resync_inline_to_current`,
which mirrors the thread-global `INLINE_STATE` used by the codegen inline
bump-allocator. `INLINE_STATE` is only meaningful for the general nursery-Eden
arena, but the same `Arena::alloc` body backs the old-gen, survivor, and
longlived arenas. When a large-object `await` allocation (e.g. a big JSON
`response.json()` payload, which is born in the old-gen) forward-scanned to
reuse an earlier old-gen block, it repointed `INLINE_STATE` at that old-gen
block. The next general-Eden `arena_alloc` then wrote the foreign block's
offset into the live Eden block, rewinding the bump pointer so a fresh string
allocation landed on top of a still-live, suspended async-step closure. When
that closure was later resumed by the microtask runner, its overwritten
function pointer (now string bytes) was jumped to → SIGSEGV.

This matches the #1824 report exactly: a garbage "function pointer" in
`js_promise_run_microtasks` / the resumed await continuation, reproducible with
no GC cycle (`full_gc=0`/`minor_gc=0`) and surviving every GC escape hatch
(`PERRY_GEN_GC=0`, `PERRY_GEN_GC_EVACUATE=0`, `PERRY_WRITE_BARRIERS=0`) — it is
an allocator-state corruption, not a collection/rooting bug.

Fix: `resync_inline_to_current` no-ops unless `self.space == NurseryEden`.
The Eden forward-scan path is unchanged; only the non-Eden arenas stop
clobbering `INLINE_STATE`.

Regression test reproduces the old-gen forward-scan reuse and asserts the
Eden `INLINE_STATE` is left intact (fails before the fix, passes after).
@proggeramlug
proggeramlug merged commit 5821971 into main May 28, 2026
10 checks passed
@proggeramlug
proggeramlug deleted the worktree-fix-1824-async-frame-resume branch May 28, 2026 04:26
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.

SIGSEGV: async frame slot mis-restored after await in a loop (js_object_get_field_by_name on garbage ptr; not GC)

1 participant