fix(gc): explicit gc() runs on precise roots — remove the forced conservative stack scan (#7558) - #7657
Conversation
|
Warning Review limit reached
Next review available in: 25 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (14)
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 |
…e the liveness probe count promotions (#7558)
Audit — merging as v0.5.1374The finding, and why I believe itThe forced conservative scan was never something I verified the load-bearing claim rather than the conclusion: Sabotage verified independently: re-adding a The detector itself is the right shape: it clears the pinned override so it sees what a production binary sees — otherwise an already-pinned mode makes Excess → 0.00% on all twelve, and
|
6c88ab3 to
173a20b
Compare
… pinned quiet host Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1. CONTROL, taken with the same binary before the pin: `--check` against the previous artifact (59d5220, #7657) reported every one of its 144 cells `ok` and failed on exactly one line — "probes present now but absent from the baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows are the new probe's, and `wt-scavtenure`'s re-pin is subsumed. Every deterministic cell in the new artifact has spread 0, including all twelve of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the suite's widest, and its median reproduces to +0.5% on an independent session). Also records the measurement-context trap this found: a probe compiled with a `package.json` in scope retains one more 1 MiB arena block at `gc()`, which makes an ad-hoc `measure --probes-dir <copy outside the repo>` report 09_try_catch_roots at -17.98% against the pin. Not a collector change; the driver and CI always compile from the repo. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
… pinned quiet host Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1. CONTROL, taken with the same binary before the pin: `--check` against the previous artifact (59d5220, #7657) reported every one of its 144 cells `ok` and failed on exactly one line — "probes present now but absent from the baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows are the new probe's, and `wt-scavtenure`'s re-pin is subsumed. Every deterministic cell in the new artifact has spread 0, including all twelve of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the suite's widest, and its median reproduces to +0.5% on an independent session). Also records the measurement-context trap this found: a probe compiled with a `package.json` in scope retains one more 1 MiB arena block at `gc()`, which makes an ad-hoc `measure --probes-dir <copy outside the repo>` report 09_try_catch_roots at -17.98% against the pin. Not a collector change; the driver and CI always compile from the repo. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
…d re-derive the #7056 RSS numbers under statepoints (#7666) * test(gc-ratchet): pin the large-Eden copying-minor cadence (#7481) and re-derive the #7056 RSS numbers under statepoints Two halves of the same job: they share the pinned quiet host and the same harness. #7481 named coverage this matrix did not have — "a live copying-minor correctness signal at exactly the cadence the ratchet probes never exercise". All twelve probes ran the shipped 16 MB nursery cap, so every copying minor they had ever exercised was small and frequent. `13_large_eden_survivors` closes that, via a per-probe `// gc-ratchet-env:` declaration that `check` compares like a metric: delete the directive and every band is still satisfied, so the arm itself has to be gated or it is not an arm. The finding that shaped the probe: a large Eden on a *small* retained set runs ZERO copying minors, because `arena_growth_full_escalation_due` escalates every minor to a full mark-sweep once arena in-use clears 32 MB and exceeds twice the post-full baseline. The first draft did exactly that and would have been pinned on a collector it never reached. #7056's RSS numbers were taken under the shadow stack, which stopped being the default in #7370. Re-derived as a 2x2 (root lowering x nursery cap) over 12 probes, 7 repeats, 3 interleaved rotations: the root lowering is not an RSS lever (peak RSS 1.002x, retention 1.000x, 104 of 108 deterministic cells bit-identical), and the nursery cap still is (1.911x peak RSS at 128 MB). Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * test(gc-ratchet): re-pin the baseline with the large-Eden arm, on the pinned quiet host Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1. CONTROL, taken with the same binary before the pin: `--check` against the previous artifact (59d5220, #7657) reported every one of its 144 cells `ok` and failed on exactly one line — "probes present now but absent from the baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows are the new probe's, and `wt-scavtenure`'s re-pin is subsumed. Every deterministic cell in the new artifact has spread 0, including all twelve of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the suite's widest, and its median reproduces to +0.5% on an independent session). Also records the measurement-context trap this found: a probe compiled with a `package.json` in scope retains one more 1 MiB arena block at `gc()`, which makes an ad-hoc `measure --probes-dir <copy outside the repo>` report 09_try_catch_roots at -17.98% against the pin. Not a collector change; the driver and CI always compile from the repo. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * chore(gc-ratchet): keep the diag regexes together, and say how a declared arm layers over the shell Cosmetic move of PROBE_ENV_RE/RESERVED_PROBE_ENV below the diag-parsing regexes they were splitting, plus a docstring line stating the layering an ad-hoc knob sweep depends on: os.environ applies to every probe that does not declare the knob, and the declaring probe wins for itself. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * docs(gc-ratchet): state the per-minor comparison exactly rather than approximately "~16 MB per minor for every default-cap probe" understated 12_large_live_set, which runs 21.8 MB because its tenured-proportional cap term already raises its Eden without any knob. The real spread is 14.6-16.6 MB on eleven of twelve and 21.8 on the twelfth, against 49.7 MB per minor for the new probe -- and the 12_large_live_set row is worth naming, because it is the shipped path to a larger Eden and shows where that path tops out. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * docs(gc-ratchet): correct the probe's own comment to the shipped numbers Its inline sizing note carried figures from a pre-final tuning run (3 minors, 36/36/68 MB) and said the survivor reads fold into the checksum when they fold into five separately-diffed lines. A stale number in the comment beside the constant it justifies is exactly the shape this campaign keeps paying for. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * chore: bump version to 0.5.1379 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Closes #7558.
What the conservative scan at
gc()actually wasA workaround for a precise-rooting hole, not a property
gc()needs.#4977 (2026-06-11) reported
const keep = {…}; gc(); keep.nested.deepreading dangling-pointer garbage: a module-init/top-level local was held only as a native-stack alloca that neither the shadow stack nor the module-var scanners covered. The fix (#4998) forced the conservative native-stack scan at the one collection site that could hide it. Nothing aboutgc()was ever argued to require a different root set from every other collection — and every automatic collection in a production binary already runs precise (conservative_stack_scan_mode()→Auto→SkipDisabled), including a full mark-sweep with precise roots at the microtask-pump safepoint (#7148'sSafepointDrainKind::OldReclaim).The hole itself was closed from the other end by the 2026-06→08 rooting campaign: pointer-typed locals get a persistent shadow slot bound in function-entry setup (#6968, #6951/#6972), module-level bindings are
@perry_global_*cells registered withjs_gc_register_global_root, andscripts/gc_root_dominance_check.pygates "a root store must dominate every collection point" with an empty allowlist.js_gc_collectis a collection point by that invariant like any other.So the scan at
gc()was grandfathered. This removes it.What it cost
gc_ratchet.py classifyonmainat961777904, all twelve probes — the residue was not the "16% on one probe" the issue reported, it was on nine of twelve and up to 31%:01_nursery_churn02_survivor_promotion03_cross_gen_writes04_dead_after_deep_stack05_closure_capture06_string_retention07_array_grow_evacuate08_map_set_sidetables09_try_catch_roots10_store_receiver_across_alloc11_collect_at_depth12_large_live_setOn this build,
classifyreports excess 0.00% and spread 0 on all twelve, and the[gc-scan-fallback] site=manual_collectcensus line is gone from every probe.The part that is a real collector-behaviour change, not just a reading
gc/tenuring.rsdeliberately refuses to seed the adaptive tenuring threshold from a cycle that ran the conservative scan ("a liveness measurement taken under it is not sound"). Every probe drives collection withgc(), so on those workloads the seed had never fired andtenuring_survivalssat at its power-on4. It fires now.On
09_try_catch_rootsand11_collect_at_depththe threshold falls4 → 1and every survivor is promoted on first copy:copied_objectspromoted_objectsheap_used_bytesheap_total_bytes09_try_catch_roots11_collect_at_depthPERRY_GC_DIAG=1on both arms confirms the copying minor still ran (eligible=true fallback=none,[gc-copy-minor] ran) and moved more objects — to old-gen instead of survivor space.freed_bytesis within 0.08%,heap_total_bytesis byte-identical, probe stdout is byte-identical, and RSS falls.That exposed a hole in the ratchet's own liveness rule, fixed here: it asserted
copied_objects > 0, butcopied_objectsandpromoted_objectscome from the same[gc-copy-minor] ranline and each names a destination. Only their sum answers "did the copying minor move anything". Left alone, the re-pin would have pinnedcopied_objects = 0on those two probes and made the rule'sbase > 0guard permanently false exactly where it had most recently fired — CLAUDE.md's fourth failure mode, inside the gate meant to close it.copied_objectskeeps its own two-sided 5% band, so the same shift is still a-100%REGRESSION row that must be re-pinned deliberately.Verification
Sabotage-verified detector, not just a green test.
explicit_gc_collects_precisely_and_a_native_stack_plant_diesplants a pointer-shaped word (both NaN-boxed and raw-I64 encodings) in a live native-stack frame as the only reference to a real malloc-GC object, callsjs_gc_collect()from that frame, and asserts the object is swept — with the per-thread scan override cleared, because the test-isolation guard's pinnedAutowould otherwise make a reintroducedforce_full_scana silent no-op. Its companionthe_native_stack_plant_survives_when_the_scan_is_pinned_onruns the identical plant with the scan pinnedFulland asserts the object survives, so a green detector means "the plant was findable and was not found", not "the plant never landed".Re-adding
force_full_scantomanual_gc_collect_nowand re-running: 0error[,Running unittestspresent, control arm stillok, detector arm FAILED on exactly the intended assertion.cargo test -p perry-runtime --lib --no-fail-fast— 1,916 passed, 0 failed.python3 -m unittest tests.test_gc_ratchet— 72 tests, green.mainbuilt identically — all 23 produce byte-identical output on both arms once pids/tids/ports are normalised. Zero regressions attributable to this change. (A regression necessarily appears as a fix-arm failure, so this covers the failure class completely.)test-files/test_issue_4977_gc_toplevel_locals.ts— correct output on the fix arm and withPERRY_CONSERVATIVE_STACK_SCAN=offonmain.mainat961777904, same pinned quiet host, same toolchain, built identically, reproduced the previous artifact and exitedgc-ratchet: OKbefore the new pin was taken. Every delta above is this change, not accumulated drift.lint-job commands,cargo fmt --all -- --check,cargo check --all-targets.Re-pin (deliberate, and required by this change — flagged rather than done silently)
benchmarks/gc_ratchet/baseline/gc-ratchet-v1.jsonis re-pinned on the pinned quiet host with the reasoning in--notes. This is not optional dressing: deleting theprobe_overridesentry requires a baseline whose12_large_live_set.heap_used_byteshas zero spread, and that cell is only bit-identical because of this change. Per #7652 the artifact also regains single provenance (143 cells were from26b9c9d59, one fromc8394bfdb).tolerances.jsonprobe_overridesis now empty — the #7554 entry was deleted because its cause is gone, which is the rule that file carries.Scope, and what is deliberately not in it
perry/gcminor()keeps its forced scan. Dropping it there makes the copying minor eligible, so the collection starts relocating survivors rather than merely retaining less — a different risk with a different proof obligation. Bundling it would have made one A/B answer two questions.ManualCollectcensus variant is deleted, not left unconstructible, matching the rule theHostPressurenote in the same enum already states: an arm nothing can produce is a claim no test can check, and itscount=0would read as "the site is quiet" when the truth is "the site is gone".gc-native-rootsis unaffected, and that was checked rather than assumed. Its arms already setPERRY_CONSERVATIVE_STACK_SCAN=off, which beats the pinnedFullthis PR removes, somainwas already precise there. Simulating that arm locally on both probes whose counters move (PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off): base and fix are identical —09_try_catch_roots6 copying minors / 388 copied on both,11_collect_at_depth5 / 259 on both,gc_evacuation_liveness_assert.pyexit 0 on both, stdout byte-identical.copied_objects/promoted_objects/minor_cyclesacross the pinned run's traced repeats, and the same values (0/6,077) on a second, different machine.Residual, stated rather than hidden
A value unrooted across
gc()with no other collection point in its window is now freed rather than conservatively retained.gc-root-dominance.ymlruns--moving-onlyandjs_gc_collectis not inPOLL_CAPABLE_RUNTIME, so that specific window shape is invisible to the gate's filtered arm (the unfiltered arm does see it —js_gc_collectis not inNONCOLLECTING).I tried to measure the residual and the measurement was vacuous, which is worth more than the number would have been. Running the shipped corpus (129 sources → 149 modules, 2,452 functions, 9,799 root stores) with
js_gc_collectadded to the mover set reportsviolations: 0— but@js_gc_collectappears in those 149.llfiles exactly 149 times, once each, every one of them adeclareand not a single call. No corpus source callsgc(), so the arm proved nothing. (For the record the unmodified gate is clean on this build:violations: 0 (moving-minor reachable: 0).)So the honest statement is: this window shape is unmeasured, and the reason is that the gate corpus cannot currently express it. Adding
js_gc_collecttoPOLL_CAPABLE_RUNTIMEis also semantically wrong — that set means "can re-enter JS", whichgc()cannot — so the right follow-up is a corpus source that callsgc()with a live local across it, plus a collector-point classification separate from the mover set. Left for a separate change rather than bolted on here.