Skip to content

fix(wasm): hold the imports object behind a token, not raw NaN-boxed bits (#9611 llhttp differential) - #9649

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9611-import-context-rooting
Closed

fix(wasm): hold the imports object behind a token, not raw NaN-boxed bits (#9611 llhttp differential)#9649
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9611-import-context-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes the last open verification item on #9611 — the llhttp differential — and fixes the bug that verification found.

#9611 listed three acceptance criteria. The three-row benchmark and the views-observability test landed with the zero-copy change; this is the third: "llhttp differential (same HTTP bytes through perry-cc and node-cc, byte-identical parse results)".

The differential

crates/perry/tests/issue_9611_llhttp_differential.rs drives both WebAssembly builds of llhttp that undici ships, extracted from a cc bundle, the way undici drives them: a windowed Uint8Array over the engine's linear memory is filled with the socket chunk, llhttp_execute runs, and the parser calls back into JS.

  • Whole-message, byte-at-a-time and 4 KiB chunkings of simple, chunked (with trailers), pipelined, 100-continue, 64-header and 300 KiB-body responses.
  • Every callback span is read both out of linear memory and through undici's own trick of mapping the wasm pointer back into the source chunk. A span that is right in one view and wrong in the other fails loudly rather than silently agreeing with itself — that is the views-observability contract, now exercised by real traffic instead of a synthetic module.
  • node's trace is the checked-in oracle. Both builds reproduce it identically, 1,216 lines.

The bug it found

The wasm host held the imports object as raw NaN-boxed bits (perry_wasm_host_instance_set_import_context), and nothing rooted or rewrote them. A collection triggered inside one import callback relocated the object, so every later import in the same call resolved a stale pointer, call_wasm_import returned 0, and the host substituted the import's default result — wasm continued with no error reported anywhere.

Through llhttp that silently dropped on_message_complete. A truncated HTTP response, ret=0, no error, on cc's network path.

It is not a regression from the zero-copy change. The same shape reproduces on a perry built from 666481e27, the commit before #9611 landed. The minimiser isolates it to allocation volume inside the callback, not to memory growth:

big_nobuild body=307200 build=false pages=3->6  complete=1
big_build   body=307200 build=true  pages=6->6  complete=0

and tenuring the imports object before instantiation — so a copying minor cannot move it — restores complete=1 on every run:

perry, imports object in nursery:   complete=0, 1, 0
perry, imports object tenured:      complete=1, 1, 1
node:                               complete=1, 1, 1

The fix

The host now holds an opaque token; the imports object stays on the runtime side in WASM_IMPORT_OBJECTS, which a registered scanner rewrites when a collection moves it. The token is assigned at instantiation, so the start function is covered too, and the per-call set_import_context store is gone — one less FFI call on the export path.

The scanner is rewrite-only (visit_metadata_nanbox_f64_slot), matching the memory binding beside it: every path that can reach an import already roots the imports object on the stack (call_captured_wasm_export roots the closure's capture for the whole call; instantiation roots it across the start function), so this table is a lookup side table, not the reference that keeps the object alive. Marking would instead pin the imports object of every instance ever created. The token counter carries a not_a_gc_pointer verdict — it is a monotonic id, never an address, which is the whole point of the indirection.

Verified

On the Linux box, against current main:

check result
llhttp differential, llhttp.wasm identical to node, 1,216 lines
llhttp differential, llhttp_simd.wasm identical to node, 1,216 lines
wasm ESM suite pass
wasm-host unit tests 14 pass
perry-runtime lib tests 3,037 pass
root-holder gate, file-size gate pass

Disabling only the rewrite (keeping the token indirection) fails the new test at exactly the right line, so the test cannot pass without the fix:

test llhttp_parses_identically_to_node ... FAILED
  node : message_complete
  perry: execute len=307243 ret=0 errpos=-76384

Fixtures

crates/perry/tests/fixtures/llhttp/ carries the two wasm builds (MIT, like undici and llhttp), the driver, node's oracle trace, and a README recording provenance and sha256 for each. 140 KB total.

With this, all three of #9611's verification items are done and the issue is ready to close.

No version bump.

https://claude.ai/code/session_01VxP3FEDgV4zUocSDBhD8qh

Summary by CodeRabbit

  • Bug Fixes

    • Fixed WebAssembly integrations that could lose import callbacks after garbage collection.
    • Prevented affected HTTP responses from being incorrectly reported as successfully parsed when parsing was incomplete.
  • Tests

    • Added comprehensive differential coverage for WebAssembly-based HTTP parsing, including chunked, pipelined, large-body, and varied chunk-size scenarios.
  • Documentation

    • Added documentation for the new HTTP parser test fixtures and expected results.

…bits (PerryTS#9611)

Closes the last open verification item on PerryTS#9611 — the llhttp differential —
and fixes the bug that verification found.

THE DIFFERENTIAL. `crates/perry/tests/issue_9611_llhttp_differential.rs`
drives both WebAssembly builds of llhttp that undici ships, the way undici
drives them: a windowed `Uint8Array` over the engine's linear memory is filled
with the socket chunk, `llhttp_execute` runs, and the parser calls back into
JS. It covers whole-message, byte-at-a-time and 4 KiB chunkings of simple,
chunked, pipelined, 100-continue, many-header and 300 KiB-body responses, and
reads every callback span BOTH out of linear memory and through undici's own
trick of mapping the wasm pointer back into the source chunk — a span that is
right in one view and wrong in the other fails loudly instead of silently
agreeing with itself. node's trace is the checked-in oracle; both builds
reproduce it identically.

THE BUG IT FOUND. The wasm host held the imports object as raw NaN-boxed bits
(`perry_wasm_host_instance_set_import_context`), and nothing rooted or
rewrote them. A collection triggered INSIDE one import callback relocated the
object, so every later import in the same call resolved a stale pointer,
`call_wasm_import` returned 0, and the host substituted the import's default
result — wasm continued with no error reported anywhere. Through llhttp that
silently dropped `on_message_complete`: a truncated HTTP response reported as
a clean parse, on cc's network path.

It is not a regression from the zero-copy change. The same binary shape
reproduces on a perry built from `666481e27` (the commit before PerryTS#9611
landed), and the minimiser isolates it to allocation volume inside the
callback, not to memory growth:

    big_nobuild body=307200 build=false pages=3->6  complete=1
    big_build   body=307200 build=true  pages=6->6  complete=0

and tenuring the imports object before instantiation — so a copying minor
cannot move it — restores complete=1 on every run. That is the diagnosis.

THE FIX. The host now holds an opaque token; the imports object stays on the
runtime side in `WASM_IMPORT_OBJECTS`, which a registered scanner rewrites
when a collection moves it. The token is assigned at instantiation, so the
start function is covered too, and the per-call
`perry_wasm_host_instance_set_import_context` store is gone — one less FFI
call on the export path.

The scanner is rewrite-only (`visit_metadata_nanbox_f64_slot`), matching the
memory binding beside it: every path that can reach an import already roots
the imports object on the stack, so this table is a lookup side table, not the
reference that keeps the object alive. Marking would instead pin the imports
object of every instance ever created.

Verified on Linux: byte-identical to node on both llhttp builds; the wasm ESM
suite, 14 wasm-host tests and 3,037 runtime tests pass; the root-holder and
file-size gates pass. Disabling only the rewrite fails the new test at exactly
the right line:

    node : message_complete
    perry: execute len=307243 ret=0 errpos=-76384

Claude-Session: https://claude.ai/code/session_01VxP3FEDgV4zUocSDBhD8qh
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 5b993a73-fc13-4e08-ad6a-5fcfea5f9c6c

📥 Commits

Reviewing files that changed from the base of the PR and between dca2bdf and f14fa14.

⛔ Files ignored due to path filters (2)
  • crates/perry/tests/fixtures/llhttp/llhttp.wasm is excluded by !**/*.wasm
  • crates/perry/tests/fixtures/llhttp/llhttp_simd.wasm is excluded by !**/*.wasm
📒 Files selected for processing (8)
  • changelog.d/9611-llhttp-import-context.md
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/webassembly.rs
  • crates/perry/tests/fixtures/llhttp/README.md
  • crates/perry/tests/fixtures/llhttp/driver.ts
  • crates/perry/tests/fixtures/llhttp/expected.txt
  • crates/perry/tests/issue_9611_llhttp_differential.rs
  • scripts/gc_runtime_root_holders.json

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The wasm host now passes opaque import tokens instead of NaN-boxed object bits. The runtime rewrites import references during GC. A new llhttp differential test compares perry output with Node across multiple parser inputs and chunk sizes.

Changes

Wasm import context and llhttp regression

Layer / File(s) Summary
GC-scanned import token storage
crates/perry-runtime/src/webassembly.rs, crates/perry-runtime/src/gc/mod.rs, scripts/gc_runtime_root_holders.json, changelog.d/9611-llhttp-import-context.md
The runtime stores imports objects in a token table and registers the table as a mutable GC root. The changelog and root-holder inventory describe the change.
Instantiation and callback token wiring
crates/perry-runtime/src/webassembly.rs
Synchronous and asynchronous instantiation pass import tokens to the host. Import callbacks resolve the current imports object through the token table.
llhttp differential validation
crates/perry/tests/fixtures/llhttp/*, crates/perry/tests/issue_9611_llhttp_differential.rs
The new driver reproduces undici llhttp execution. The test compares both wasm builds with Node output across response types and chunk sizes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to f14fa

No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 4 files. (4 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main wasm fix: replacing raw NaN-boxed import context data with an opaque token. It also references the llhttp differential that exposed the bug.
Description check ✅ Passed The description provides a detailed summary, concrete changes, related issue reference, test plan, verification results, fixture details, and confirmation that no version bump was included. It does no…
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.
Full details: Description check

Explanation

The description provides a detailed summary, concrete changes, related issue reference, test plan, verification results, fixture details, and confirmation that no version bump was included. It does not use the exact template headings or checklist format, but it contains the required substantive information and is mostly complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 4 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #9653 (rebase-merge, authorship preserved).

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Note on the red gc-ratchet check: it is not from this PR.

That gate has failed on every main-line run for the last three days, including 666481e27 — the commit before the #9611 zero-copy change landed:

2026-09-03T16:20:05Z failure ed7019ea8
2026-09-03T11:05:55Z failure 666481e27
2026-09-03T04:14:16Z failure 898e53159
2026-09-02T20:57:41Z failure 8d27f7b96
...

Its pinned baseline is stale: most of the rows it flags as REGRESSION are decreases (copied_bytes -39%, promoted_bytes -16.5%, freed_bytes -16.5%), which is the identity check firing in both directions rather than a perf regression. Re-pinning it belongs with whoever owns the GC work that moved those numbers, not here.

This PR cannot affect it in any case: the scanner it adds is behind #[cfg(feature = "wasm-host")], and the gc-ratchet scenarios are non-wasm programs that never compile it in.

The first batch of checks also shows as failed — those were cancelled when I added run-extended-tests, and gh pr checks renders cancelled and failed identically.

https://claude.ai/code/session_01VxP3FEDgV4zUocSDBhD8qh

@proggeramlug
proggeramlug deleted the fix/9611-import-context-rooting branch September 3, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant