Skip to content

fix(gc): root fs.readdir's options object across the withFileTypes key allocation (#7274) - #7693

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7274-readdir-options-rooting
Aug 9, 2026
Merged

fix(gc): root fs.readdir's options object across the withFileTypes key allocation (#7274)#7693
proggeramlug merged 3 commits into
mainfrom
fix/7274-readdir-options-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #7274.

The defect

crates/perry-runtime/src/fs/dirent.rs::options_with_file_types decoded a raw
*const ObjectHeader out of the NaN-boxed options argument, then called
js_string_from_bytes(b"withFileTypes") — a collection point — and then
dereferenced the address it had computed before the collection. options_value
is a plain Rust f64 local, so nothing kept the object alive and nothing
rewrote the pointer.

{ withFileTypes: true } is a fresh object literal at the call site, i.e. a
nursery object — precisely the generation an evacuating minor relocates.

options_field_value, 40 lines below in the same file, has the same signature
and already did it correctly. The bug is the drift between the two.

The fix

  • The allocation is hoisted above the decode, bound together with
    RuntimeHandle::across_nanbox so there is no pre-collection address in scope
    to reach for by accident, and options_value is rooted in a
    RuntimeHandleScope.
  • The decode itself — POINTER_TAG / raw-address forms plus the lint: addr_class_inventory fails on main — 2 real ratchet regressions plus a stale allowlist substring #7259
    is_handle_band floor — is factored into one options_object_ptr helper.
    It appeared three times in this file; two copies drifting is what this bug
    was, so the duplication is removed rather than fixed twice.

No signature change, no caller churn.

Which configuration this actually bites in

Stated precisely, because the honest answer is narrower than "shipped default
segfaults" and the test file says so out loud:

gc_check_trigger's alloc-point arm engages
ManualGcScanGuard::force_full_scan(NurseryChurnSlackValve) — unconditional
since #7682. When that guard engages, the conservative native-stack scan
both retains the raw local and makes the copying minor ineligible
(CopiedMinorFallbackReason::ConservativeStack), so the pre-fix code survives:
the address neither dies nor moves.

It does not always engage. force_full_scan is a no-op when
CONSERVATIVE_STACK_SCAN_OVERRIDE is already set, and
conservative_stack_scan_mode lets an explicit PERRY_CONSERVATIVE_STACK_SCAN
env value beat any pin. So PERRY_CONSERVATIVE_STACK_SCAN=off — the arm every
issue in this family reproduces on — removes the valve and the alloc-point minor
evacuates.

So: the masking mechanism is the bounded valve #7148 documents as a bounded
valve, not a guarantee. This PR closes the hazard rather than relying on it.

Witness

Two knob-free unit tests in
crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs. They
drive a real evacuating minor from inside the function's own key allocation
and run in exactly the configuration described above (CopyingNurseryTestGuard
pins the scan mode to Auto, which resolves to SkipDisabled — the same
decision PERRY_CONSERVATIVE_STACK_SCAN=off produces).

The subject is asserted live, per CLAUDE.md's "a gate must assert its subject
was live":

  • the options object is held by nothing except the function under test — no
    shadow slot, no registered root;
  • a separately rooted sentinel allocated in the same nursery must come back
    at a different address, so a cycle that moved nothing cannot certify the file.

That assertion earned its place immediately: the first draft pinned
force_legacy_gc_pacing() (scavenge off, polls off), which routes the trigger to
the budgeted stepper — non-moving by construction. The sentinel did not move and
the test failed rather than passing vacuously. Pacing is now left at the shipped
default, deliberately, and the file records why.

Sabotage

Reverting options_with_file_types to the pre-fix decode-then-allocate order:

running 2 tests
test ...::readdir_options_object_survives_the_with_file_types_key_allocation ... FAILED
test ...::readdir_options_without_the_field_stays_false_across_the_key_allocation ... ok

panicked at .../fs_options_object.rs:94:5:
options_with_file_types read `withFileTypes` through the address it computed
BEFORE the key allocation (object was at 0x20001300008); the collection at that
allocation moved/reclaimed the object, so the read landed on retired from-space

The negative test stays green under sabotage — a stale read yields not-truthy
just as readily as a correct one. It is labelled NOT A DISCRIMINATOR in the
source so its passing is never read as evidence about the rooting; it guards the
None/false arms of the rewritten decode, which the positive test never reaches.

Gates run locally

cargo fmt --all -- --check, cargo test -p perry-runtime --lib,
check_file_size.sh, check_test_registration.py, global_sink_isolation.py,
addr_class_inventory.py — all green.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed fs.readdir handling when withFileTypes options are processed during garbage collection.
    • Ensured directory entry type detection remains correct even when objects move in memory.
    • Preserved expected behavior for options with or without withFileTypes.
  • Tests

    • Added regression coverage for object movement and liveness during fs.readdir processing.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The filesystem options decoder now roots option objects across key allocation, refreshes relocated pointers, and centralizes pointer validation. Runtime-root tests cover moving collections for present and absent withFileTypes fields.

Changes

Filesystem options GC safety

Layer / File(s) Summary
Root and refresh options objects
crates/perry-runtime/src/fs/dirent.rs, changelog.d/7693-readdir-options-rooting.md
options_with_file_types roots options across key allocation and reads the refreshed value. Shared decoding rejects invalid and handle-band pointers. options_field_value uses the same decoder.
Validate relocation behavior
crates/perry-runtime/src/gc/tests/runtime_roots.rs, crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs
Runtime-root tests force moving collection during lookup and verify both present and absent withFileTypes fields.

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

Possibly related PRs

  • PerryTS/perry#6857: Both update options_field_value to root and refresh option object pointers across GC-triggering allocations.
  • PerryTS/perry#6941: Both fix GC evacuation hazards by rooting runtime values and rereading moved pointers.
  • PerryTS/perry#7687: Both address GC movement during allocation-triggered collections in runtime paths.

Suggested labels: bug, rust

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main garbage-collection rooting fix in fs.readdir options handling.
Description check ✅ Passed The description explains the defect, fix, scope, linked issue, regression tests, and verification commands in sufficient detail.
Linked Issues check ✅ Passed The implementation roots the options object, refreshes the pointer after allocation, centralizes decoding, and adds tests for issue #7274.
Out of Scope Changes check ✅ Passed The changes are limited to the fs.readdir rooting fix, related pointer-decoding cleanup, changelog documentation, and regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 fix/7274-readdir-options-rooting

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.

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

Actionable comments posted: 2

🤖 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/fs/dirent.rs`:
- Around line 140-153: Update options_object_ptr to validate raw_ptr with the
canonical crate::value::addr_class::is_plausible_heap_addr predicate before
converting it to an ObjectHeader pointer. Replace the existing
is_handle_band-only rejection while preserving the current None return for
invalid addresses.

In `@crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs`:
- Around line 152-160: Update the negative-test setup after js_object_alloc in
the relevant test to assert pointer_in_nursery(obj as usize), matching the
positive test’s nursery-membership check before collection. Keep the existing
options-field initialization and relocation sentinel unchanged.
🪄 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: 81cbdbd8-e808-416a-8b74-bda5f9cb4cda

📥 Commits

Reviewing files that changed from the base of the PR and between 8d27032 and 0bd8985.

📒 Files selected for processing (4)
  • changelog.d/7693-readdir-options-rooting.md
  • crates/perry-runtime/src/fs/dirent.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs

Comment on lines +140 to +153
unsafe fn options_object_ptr(options_value: f64) -> Option<*const crate::object::ObjectHeader> {
let bits = options_value.to_bits();
let value = crate::value::JSValue::from_bits(bits);
let raw_ptr = if value.is_pointer() {
value.as_pointer::<crate::object::ObjectHeader>() as usize
} else if bits >> 48 == 0x0000 {
(bits & 0x0000_FFFF_FFFF_FFFF) as usize
} else {
return false;
return None;
};
// #7259: a POINTER_TAG payload can be a registry handle id rather than a
// heap address, and `< 0x1000` sits an order of magnitude below
// `HANDLE_BAND_MAX` — fetch/zlib/proxy ids passed it and were dereferenced
// as an ObjectHeader (the Linux-only fault class of #1843/#4004/#6271).
// `is_handle_band` also subsumes the null check that used to follow.
if crate::value::addr_class::is_handle_band(raw_ptr) {
return false;
return None;
}
let obj_ptr = raw_ptr as *const crate::object::ObjectHeader;
let key = crate::string::js_string_from_bytes(b"withFileTypes".as_ptr(), 13);
Some(raw_ptr as *const crate::object::ObjectHeader)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use the canonical heap-address predicate.

Line 150 rejects only the handle band. Replace this check with crate::value::addr_class::is_plausible_heap_addr(raw_ptr) before converting raw_ptr to *const ObjectHeader. This shared decoder can otherwise pass non-heap raw addresses to field access and cause an invalid dereference.

Proposed fix
-    if crate::value::addr_class::is_handle_band(raw_ptr) {
+    if !crate::value::addr_class::is_plausible_heap_addr(raw_ptr) {
         return None;
     }

Based on learnings: “use the canonical predicate crate::value::addr_class::is_plausible_heap_addr for the handle-band/heap-floor check.”

📝 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
unsafe fn options_object_ptr(options_value: f64) -> Option<*const crate::object::ObjectHeader> {
let bits = options_value.to_bits();
let value = crate::value::JSValue::from_bits(bits);
let raw_ptr = if value.is_pointer() {
value.as_pointer::<crate::object::ObjectHeader>() as usize
} else if bits >> 48 == 0x0000 {
(bits & 0x0000_FFFF_FFFF_FFFF) as usize
} else {
return false;
return None;
};
// #7259: a POINTER_TAG payload can be a registry handle id rather than a
// heap address, and `< 0x1000` sits an order of magnitude below
// `HANDLE_BAND_MAX` — fetch/zlib/proxy ids passed it and were dereferenced
// as an ObjectHeader (the Linux-only fault class of #1843/#4004/#6271).
// `is_handle_band` also subsumes the null check that used to follow.
if crate::value::addr_class::is_handle_band(raw_ptr) {
return false;
return None;
}
let obj_ptr = raw_ptr as *const crate::object::ObjectHeader;
let key = crate::string::js_string_from_bytes(b"withFileTypes".as_ptr(), 13);
Some(raw_ptr as *const crate::object::ObjectHeader)
unsafe fn options_object_ptr(options_value: f64) -> Option<*const crate::object::ObjectHeader> {
let bits = options_value.to_bits();
let value = crate::value::JSValue::from_bits(bits);
let raw_ptr = if value.is_pointer() {
value.as_pointer::<crate::object::ObjectHeader>() as usize
} else if bits >> 48 == 0x0000 {
(bits & 0x0000_FFFF_FFFF_FFFF) as usize
} else {
return None;
};
if !crate::value::addr_class::is_plausible_heap_addr(raw_ptr) {
return None;
}
Some(raw_ptr as *const crate::object::ObjectHeader)
🤖 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 `@crates/perry-runtime/src/fs/dirent.rs` around lines 140 - 153, Update
options_object_ptr to validate raw_ptr with the canonical
crate::value::addr_class::is_plausible_heap_addr predicate before converting it
to an ObjectHeader pointer. Replace the existing is_handle_band-only rejection
while preserving the current None return for invalid addresses.

Source: Learnings

Comment on lines +152 to +160
let obj = crate::object::js_object_alloc(0, 1);
let key = crate::string::js_string_from_bytes(b"encoding".as_ptr(), 8);
let encoding = crate::string::js_string_from_bytes(b"utf8".as_ptr(), 4);
crate::object::js_object_set_field_by_name(
obj,
key,
f64::from_bits(string_bits(encoding as usize)),
);
let options_value = f64::from_bits(ptr_bits(obj as usize));

Copy link
Copy Markdown

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 that the negative-test options object is movable.

The sentinel proves that the minor collection moved, but it does not prove that obj was in the nursery. Add pointer_in_nursery(obj as usize) after allocation, as the positive test does. Without this assertion, the test can pass when the options object does not relocate.

Proposed fix
     let obj = crate::object::js_object_alloc(0, 1);
+    assert!(
+        crate::arena::pointer_in_nursery(obj as usize),
+        "the options object must be movable or this test does not exercise relocation"
+    );
     let key = crate::string::js_string_from_bytes(b"encoding".as_ptr(), 8);
📝 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
let obj = crate::object::js_object_alloc(0, 1);
let key = crate::string::js_string_from_bytes(b"encoding".as_ptr(), 8);
let encoding = crate::string::js_string_from_bytes(b"utf8".as_ptr(), 4);
crate::object::js_object_set_field_by_name(
obj,
key,
f64::from_bits(string_bits(encoding as usize)),
);
let options_value = f64::from_bits(ptr_bits(obj as usize));
let obj = crate::object::js_object_alloc(0, 1);
assert!(
crate::arena::pointer_in_nursery(obj as usize),
"the options object must be movable or this test does not exercise relocation"
);
let key = crate::string::js_string_from_bytes(b"encoding".as_ptr(), 8);
let encoding = crate::string::js_string_from_bytes(b"utf8".as_ptr(), 4);
crate::object::js_object_set_field_by_name(
obj,
key,
f64::from_bits(string_bits(encoding as usize)),
);
let options_value = f64::from_bits(ptr_bits(obj as usize));
🤖 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 `@crates/perry-runtime/src/gc/tests/runtime_roots/fs_options_object.rs` around
lines 152 - 160, Update the negative-test setup after js_object_alloc in the
relevant test to assert pointer_in_nursery(obj as usize), matching the positive
test’s nursery-membership check before collection. Keep the existing
options-field initialization and relocation sentinel unchanged.

Ralph Küpper added 3 commits August 9, 2026 14:55
…y allocation (#7274)

`fs/dirent.rs::options_with_file_types` decoded a raw `*const ObjectHeader`
out of the NaN-boxed `options` argument, THEN called
`js_string_from_bytes(b"withFileTypes")` — a collection point — and THEN
dereferenced the address it had computed before the collection. `options_value`
is a plain Rust `f64` local, so nothing kept the object alive and nothing
rewrote the pointer.

The allocation is now hoisted above the decode and the options value is rooted
in a `RuntimeHandleScope`, so the address only ever comes back out of a slot the
collector rewrote — the shape `options_field_value` 40 lines below already used.
The decode itself (POINTER_TAG / raw-address forms plus the #7259 handle-band
floor) is factored into one `options_object_ptr` helper, because this file
performed it in three places and the drift between two of them is what this bug
was.

Witness: two knob-free unit tests in
`gc/tests/runtime_roots/fs_options_object.rs` drive a real evacuating minor from
inside the function's own key allocation. The options object is held by nothing
else, and a separately rooted sentinel must come back at a different address —
so a cycle that moved nothing cannot certify the file.

Sabotage-verified: restoring the pre-fix decode-then-allocate order fails
`readdir_options_object_survives_the_with_file_types_key_allocation`.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1398

Sabotage-verified independently: restoring the pre-fix order (decode → allocate → deref) reddens readdir_options_object_survives_the_with_file_types_key_allocation, error[ 0, test binary reached. Green on restore.

The framing I want on the record is yours: "the bug is the drift between the two." options_field_value sits 40 lines below in the same file with the same signature and already did it correctly. That is the most common way this class arrives — not someone getting it wrong, but a sibling written later or edited without the neighbour in view.

Hoisting the allocation above the decode and binding it with across_nanbox is better than rooting-and-re-reading, for the reason the comment gives: there is then no pre-collection address in scope to reach for by accident. That is a shape fix rather than a site fix.

{ withFileTypes: true } being a fresh literal at the call site — exactly the young object the next allocation's minor relocates — is what makes this reachable rather than theoretical.

Gates: 24/24 lint, fmt clean, perry-runtime --lib all green.

@proggeramlug
proggeramlug force-pushed the fix/7274-readdir-options-rooting branch from 57da27f to dd78ae1 Compare August 9, 2026 13:00
@proggeramlug
proggeramlug merged commit 3dbbcc7 into main Aug 9, 2026
@proggeramlug
proggeramlug deleted the fix/7274-readdir-options-rooting branch August 9, 2026 13:00
proggeramlug added a commit that referenced this pull request Aug 9, 2026
* fix(gc): restore the raw-handle debt baseline after #7693

#7693's test asserts a pre/post address comparison, the shape across_* exists
to make unnameable, so it is listed rather than converted. Four pairs converted
elsewhere to hold the baseline at 998.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* chore: bump version to 0.5.1399

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

fs.readdir: options_with_file_types dereferences an unrooted object pointer across an allocation

1 participant