Fix a crash when reading a method off a Buffer without calling it - #7747
Conversation
…ring js_class_method_bind stores the method-name POINTER in the bound closure and dispatch_bound_method re-reads it at call time; its doc requires the pointer to stay stable for the closure's lifetime, which codegen satisfies with rodata. Two runtime callers on the Buffer path did not. get_field_by_name_tail passed key + size_of::<StringHeader>() -- the interior of a movable GC heap string that is unreachable once the read returns -- so a copying minor could relocate or reclaim the bytes the closure names. The computed-key arm in polymorphic_index bound a local String's bytes, which dangle on return with no collector involvement at all. Both now bind a 'static literal. The buffer_dispatch name list becomes one macro-generated source for is_buffer_method_name and a new buffer_method_name_static that returns the literal rather than a borrow of its argument. Whether the stale bytes still spell the method is an allocator property, not a program property, which is why this passed locally and took a SIGSEGV on conformance-smoke shards 7 and 8.
7205ac0 to
c1c4524
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughBuffer method lookup now binds stable static method-name literals in property and computed-key paths. A shared macro generates method recognition and static-name lookup. GC tests verify pointer ownership and lifetime. ChangesBuffer method-name lifetime
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 `@crates/perry-runtime/src/gc/tests/buffer_bound_method_name.rs`:
- Around line 87-95: Update the computed-key test assertions around the captured
method name to compare name_ptr against
buffer_method_name_static("readUInt8").unwrap().as_ptr() rather than
key_interior. Keep the existing byte-content assertion and ensure the test
verifies capture of the static literal pointer.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8fcf067b-b755-4b10-98dc-36d8c531b75c
📒 Files selected for processing (7)
changelog.d/7747-buffer-bound-method-name-lifetime.mdcrates/perry-runtime/src/gc/tests/buffer_bound_method_name.rscrates/perry-runtime/src/gc/tests/mod.rscrates/perry-runtime/src/object/buffer_dispatch.rscrates/perry-runtime/src/object/field_get_set/buffer_own_prop.rscrates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rscrates/perry-runtime/src/object/polymorphic_index.rs
The inequality against the key string could pass with the bug present: the broken computed-key path captured a local String's bytes, which are neither the key's interior nor the literal. Comparing the BYTES only fails on a host where the freed memory has already been reused, which is the lucky-allocator problem these tests exist to avoid. Identity with the literal cannot be lucky.
✅ Action performedComments resolved. Approval is disabled; enable |
Merging as v0.5.1437 — a real use-after-free, and the diagnosis is exactly right
Both violate a contract the function's own doc already stated: "Method-name pointer is expected to be stable for the closure's lifetime; codegen emits it from the per-module The single-source macro is the right shape. Auditing the other callers of the same function and reporting them ( The test is the right instrument, and I verified it bites
That matters because the runtime symptom is not deterministic, and I proved that the hard way. My first attempt to reproduce the crash ran the two gap fixtures under
One note: both fixtures are absent from |
The fork PR was squash-merged from its own head, so the rebase's fmt fix and version bump did not land with it. main was red on `cargo fmt --check`. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Fixing a crash when you read a method off a Buffer
Reading a method from a Buffer without calling it —
typeof buf.readUInt8,const f = buf.readUInt8, orbuf[someKey]— produced a function that pointed at memory which had already been freed or moved. Calling it later read whatever happened to be sitting there.This fixes the segmentation fault on
test_gap_buffer_own_propsandtest_gap_buffer_own_prop_shadow_intrinsic_6405in the conformance test suite. Neither test is listed ingap_snapshot.json, meaning both are expected to pass.What was wrong
When you read a method off a Buffer but do not call it, Perry has to hand you back something callable. It builds a small object that remembers two things: which Buffer you read it from, and the name of the method. When you eventually call it, that name is looked up and the real method runs.
The catch is that it remembers the name as a pointer — an address in memory — rather than copying the text. So whatever it points at has to stay valid and unchanged for as long as that callable object might be used. The function's own documentation says exactly this:
The compiler holds up its end: the names it passes live in a read-only section of the executable and never move. Two places in the runtime did not:
get_field_by_name_tailpolymorphic_index, thebuf[key]pathStringlocalIn both cases you got back something that looked like a working function and was, in fact, pointing at memory nobody owned any more.
The fix
Both places now pass a pointer to a string literal baked into the executable, which is never freed and never moves — the same guarantee the compiler already provides.
To make that possible without maintaining a second copy of the list of Buffer method names, the existing list in
buffer_dispatchnow generates two things from one source: the existing yes/no check, and a new lookup that returns the matching literal. The yes/no check compiles to exactly what it did before, and the new lookup is only reached on the path that was already about to allocate, so nothing on a hot path got slower.I checked the other callers of the same function while I was in there. The ones in
symbol/get.rspass byte-string literals, which already live in read-only memory, and the three indescriptors.rsand the class registry already make a permanent copy. These two were the only ones getting it wrong.Why this looked like a flaky test rather than a bug
Whether reading freed memory actually goes wrong depends on whether anything has reused that memory yet. That is a property of the memory allocator on the machine you happen to be running on — not a property of the program.
So this passed consistently on macOS while segfaulting on Linux in CI. Worse, it looked like unrelated changes were causing it: an in-flight pull request that made the garbage collector run more often flipped
test_gap_buffer_own_propsfrom passing to crashing without touching a single line of Buffer code. More collections meant more churn, which meant the freed bytes were more likely to have been reused by the time anything read them.That is exactly the kind of bug that gets triaged as "flaky, re-run it".
The tests, and why they check the shape rather than the symptom
The obvious test — call the method after forcing a collection and see whether it still works — is a bad test here, because it asks the machine's allocator a question rather than asking the program one. It would pass on a lucky host with the bug fully present, which is the failure mode that let this ship in the first place.
So the three tests in
gc/tests/buffer_bound_method_name.rscheck the underlying rule instead:buf[key]path's remembered name must not point at a temporary.These live under
gc/tests/so they run in the required per-pull-requestcargo-testjob, rather than the nightly-only tier where a regression could sit red for days.They were checked by deliberately breaking the fix. With the two corrected call sites reverted and the tests left alone, the first two fail — and the second one fails because the remembered name had already turned into garbage bytes. So the use-after-free reproduces deterministically, in-process, on the same machine where the end-to-end tests were happily passing.
How this was verified
cargo test -p perry-runtime --lib— 1979 passed, 0 failed, 4 ignored.scripts/check_file_size.shpasses.No version bump — the maintainer bumps that at merge time, per the external-contributor flow.
Summary by CodeRabbit
Bug Fixes
Tests