Repository navigation
GC follow-ups from #7733: one-way prototype latch (#7510 shape), missing positive-direction pacing test, unmeasured grow-then-churn transition, advisory-only ratchet #7737
Description
Activity
- added 12 commits that reference this issue
on Aug 10, 2026 Triage against
main@55fd197d5(2026-08-12). Three of the four items are already closed in-tree; the fourth is real, still open, and its stated prerequisite has since gone stale in the other direction.1. One-way
OBJECT_PROTOTYPES_NONEMPTYlatch — DONEprototype_chain.rsnow releases the latch whenprune_dead_object_prototype_owners'sretain()drains the map (:345,:357), and latches before the insert and under the lock (:222). Covered bymod latch_drain_tests_7737(:584), whose failure message is the issue's own framing — "the registry is empty but the latch is still armed".2. Positive-direction pacing test — DONE
gc/tests/triggers.rs:853carriesescalating_records_the_pre_full_arena_reading, labelled "#7737 item 2", asserting atrueverdict from the realarena_growth_full_escalation_due()leaves a non-zeropre_in_usethat equals the reading the predicate decided on. The "not mockable today" blocker was closed by a#[cfg(test)]seam in front ofpacing_escalation_reading_bytes()(policy.rs:1894) — thread-local, not env, per #7946's rule. A third test (the_recorded_reading_is_the_one_the_predicate_decided_on) additionally pins that the predicate and the recording read the same metric, which the issue did not ask for and is the sharper property.3. Grow-then-churn transition — DONE, and it refuted the issue's prediction
benchmarks/gc_ratchet/probes/14_grow_then_churn.tsexists and is in the baseline (gc-ratchet-v1.json:74). Worth reading rather than assuming: the issue reasoned this shape would cost "up to 4× more transient RSS". The same-binary A/B in the probe header (shippedMAJOR_PACING_BACKOFF_SHIFT_MAX = 2vs0) found the escalation boundary does move 4× (56.3 MB vs 14.1 MB) — and peak RSS does not follow it, and does not even move in the predicted direction: the shipped backoff is 2.2 MB (3%) lower, reproducibly, while running two fewer full mark-sweeps for identical collector work (same 24 copying minors, same 306,715 objects moved). Footprint on this shape is set by the steady state the minors hold, not by cumulative stub debt.4.
gc-ratchet/gc-stressare not required contexts — still open, and now blockedConfirmed unchanged today:
$ gh api repos/PerryTS/perry/branches/main/protection --jq .required_status_checks.contexts ["lint","cargo-test","parity","compile-smoke","api-docs-drift","security-audit","conformance-smoke-complete"]But the issue's prerequisite — "a new gate should be observed green on
mainonce first, which recent runs now satisfy" — no longer holds. Both gates are currently RED onmain:gc-ratchet, last 30mainruns: 0 success, 13 failure, 15 cancelled. Most recent completed non-cancelled run (31526901962) endsgc-ratchet: FAILED.gc-stress, last three completedmainruns oftest.yml(31460223673,31358183869,31295652054): failure in all three.
So promotion today would block every open PR — CLAUDE.md's own corollary ("a new gate has never been green, so promoting it to required immediately blocks every open PR. Run it once, then promote."). The ordering is: get both green on
main, then promote. Recording it here so the next reader does not act on the issue's now-stale "recent runs now satisfy".Also worth noting for whoever picks this up: the cancelled-run count is itself a symptom — 15 of 30
gc-ratchetmainruns never executed. That is theconcurrency/cancel-in-progressshape CLAUDE.md lists as the third way a gate cannot fail, and it makes "observed green onmainonce" harder to reach than it looks.Suggestion: items 1–3 can be struck; retitle to item 4 alone (or close and open a fresh "promote
gc-ratchet+gc-stressto required" issue with the green-first ordering spelled out), since a four-item issue that is 75% done reads as 100% open.Fresh reconciliation against
origin/main@08173f8b403be848f2218746b22a462fd883042f: items 1–3 are closed in-tree; item 4 is the only remaining scope. No new deterministic regression gap turned up.-
One-way prototype latch — DONE. fix(gc): release the prototype-registry latch when a prune drains it (#7737) #7740 merged as
caaab6b984a9ceab1ff305962fe2c074d9a81710, still an ancestor ofmain. The writer publishes under the registry lock before insertion, pruning clears the latch whenretain()drains the map, anda_drained_prototype_registry_releases_the_fast_path_latchpins the regression. -
Positive-direction pacing recording — DONE. gc: assert the positive direction of arena-growth pacing recording, on one shared accessor (#7737 item 2) #7752 merged as
fb093cd650b00cf193804b49b9643778243b0014, still an ancestor.escalating_records_the_pre_full_arena_readingcalls the realarena_growth_full_escalation_due()and requires the exact non-zero reading back; the adjacent same-metric regression remains too. The predicate still records internally on a true verdict, through the thread-local#[cfg(test)]seam rather than timing or allocation pressure. -
Grow-then-churn transition — DONE; predicted footprint regression refuted by the landed measurement. test(gc): measure the grow-then-churn pacing transition and pin it as a ratchet probe (#7737 item 3) #7783 merged as
db44b31b7ab83505d3b28159ee9278a2b938669f, still an ancestor.14_grow_then_churn.tsrecords the same-binary A/B: the boundary moved 4×, but the shipped cap-2 arm had 2.2 MB (3%) lower peak RSS and two fewer fulls with identical minor/move work. The probe remains registered in the pinned artifact, and the harness pins both directives required to reach the transition. -
Promotion to required contexts — OPEN / tracker-only. The live branch-protection query still returns exactly
lint,cargo-test,parity,compile-smoke,api-docs-drift,security-audit, andconformance-smoke-complete;gc-ratchetandgc-stressare absent. That matches the current workflow source:gc-ratchetis fail-capable but not required, andtest.ymlstill explicitly saysgc-stressis not yet in required contexts. Promotion should wait for the reliability/freshness work tracked by CI gate freshness alert: 6 gate(s) have no recent successfulmainrun #7966 and, specifically for the ratchet baseline, the deterministic counter attribution/re-pin work tracked by GC ratchet has broad deterministic fingerprint shifts that need attribution #8051. This conclusion does not use a red CI run as correctness evidence; the unresolved attribution/reliability prerequisites and current branch-protection state are sufficient.
Exact remaining order: resolve #7966 and #8051; observe both context names reporting green on
main; then have an admin addgc-ratchetandgc-stressto required status checks and verify they report on PRs. Leaving #7737 open solely for that promotion sequence—there is no additional item 1–3 implementation or test PR to cut.-
Closing. Independent re-verification against
origin/main@0d7fe21b0— and unlike
the two triage comments above, this one ran the tests rather than reading them,
and sabotage-verified that each can fail.1. One-way
OBJECT_PROTOTYPES_NONEMPTYlatch — DONE, and the test is discriminatingFix present:
crates/perry-runtime/src/object/prototype_chain.rs:222latches
Releasebefore the insert and under the registry mutex;:353-358
prune_dead_object_prototype_ownersclears the latch whenmap.is_empty().
caaab6b98(#7740) is an ancestor of0d7fe21b0.Sabotage. Reverted the fix to the pre-#7740 one-way latch
(if map.is_empty() {→if false && map.is_empty() {), real rebuild
(grep -c "Compiling perry-runtime v" = 1):test object::prototype_chain::latch_drain_tests_7737::a_drained_prototype_registry_releases_the_fast_path_latch ... FAILED #7737: the registry is empty but the latch is still armed, so every evacuated object keeps paying the mutex + SipHash lookup for the rest of the processRestored, real rebuild,
test result: ok. 5 passed; 0 failed. The test is not one of
the ones that pass under sabotage of the line they claim to guard.Residual-hole check, which the issue's "or replace it with a real emptiness check"
invites.OBJECT_PROTOTYPES_NONEMPTYandget_object_prototypes()are referenced
only inprototype_chain.rs— zero hits anywhere else undercrates/. The two
mutation sites other than prune are cardinality-preserving remove-then-reinsert
(object_static_prototype_owner_moved:380,
visit_object_static_prototype_slot_mut:416/:432). So prune is the only path that
can drain the map, and it now clears the latch. A real emptiness check would buy
nothing over the fix that landed.2. Positive-direction pacing test — DONE, and both directions are now covered
gc/tests/triggers.rs:853 escalating_records_the_pre_full_arena_readingand
:917 the_recorded_reading_is_the_one_the_predicate_decided_on. The
"arena_in_use_bytes()isn't mockable today" blocker was closed with a#[cfg(test)]
seam in front ofpacing_escalation_reading_bytes()(policy.rs:1895), thread-local
rather than env.fb093cd65(#7752) is an ancestor.Sabotage. Reverted the recording to the #7726 first-cut no-op
(if due { note_full_cycle_started(); }→if false && due { … }), same rebuild:escalating_records_the_pre_full_arena_reading ................... FAILED left: 0 right: 33554433 the_recorded_reading_is_the_one_the_predicate_decided_on ........ FAILED left: 0 right: 33554433 declining_to_escalate_records_no_pre_full_reading ............... okThat third line is the point of the item, now measured rather than argued: the
pre-existing negative-only test stays green through the exact bug it was the only
coverage for.3. Grow-then-churn transition — DONE, and it is LIVE in CI, not merely present
benchmarks/gc_ratchet/probes/14_grow_then_churn.ts(db44b31b7, #7783, ancestor),
pinned inbaseline/gc-ratchet-v1.json:74, tolerances at
tests/test_gc_ratchet.py:514-517, arm assertion at:1255.Ran the harness:
python3 -m unittest tests.test_gc_ratchet→ Ran 97 tests … OK.Sabotage-verified the arm assertion — deleting
// gc-ratchet-env: PERRY_GC_MAJOR_PACING_FLOOR_MB=1from the probe gives
AssertionError: {'PERRY_GC_SCAVENGE_NURSERY_MB': '1'} != {…, 'PERRY_GC_MAJOR_PACING_FLOOR_MB': '1'};
restored → OK, tree clean.And the probe genuinely executes in CI, which is the part reading the file cannot
tell you.gc-ratchetrun31831865551(2026-08-14,506f4ab11):14_grow_then_churn: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.075594% peak_rss_bytes=0.080871% wall_ms=17.099233% … gc-ratchet: OKThe issue's prediction ("up to 4× more transient RSS") was refuted by the landed
measurement, which the probe header records: the escalation boundary does move 4×
(56.3 MB vs 14.1 MB) but peak RSS moves 2.2 MB (3%) in the opposite direction.4. Advisory-only ratchet — SPLIT OUT
Still true: required contexts are exactly
lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit, conformance-smoke-complete; neithergc-ratchetnorgc-stressis among them.But the last triage's "promotion today would block every open PR" is now stale in
the other direction.gc-ratchethas four consecutive greenmainruns
(2026-08-14, on the four commits before0d7fe21b0), the flip is attributable to
f9475fb9a/ #8069, and both named blockers (#7966, #8051) are closed. CLAUDE.md's
"run it once, then promote" precondition is met forgc-ratchettoday.gc-stressis still red onmain— eight consecutive nightly runs — but for exactly
one reason, and it is not a GC reason:specabi_reassignfails in 22 of 22 arms of
scripts/gc_repsel_matrix.sh,shipped_defaultincluded, while every other row is
PASS or UNVER. I reproduced it locally at0d7fe21b0and reduced it to five lines —
reassigning anew Int32Arraybinding to a plain array makes every element READ return
0, becausejs_typed_array_getanswers0.0on aclean_ta_ptrrejection instead of
falling back to the ordinary[[Get]]. That is the exact mirror of #8090, and it is now
#8100.Successors:
- Promote
gc-ratchetto a required context now (precondition met);gc-stressafter #8100 #8101 — promotegc-ratchetnow (precondition met),gc-stressafter Reassigning anew Int32Arraybinding to a plain array makes every element READ return 0 (silent miscompile; sole reasongc-stressis red) #8100.
Carries the run tables, the admin command, and the ordering. - Reassigning a
new Int32Arraybinding to a plain array makes every element READ return 0 (silent miscompile; sole reasongc-stressis red) #8100 — thespecabi_reassignmiscompile, the whole of what stands between
gc-stressand a greenmain.
Closing this rather than leaving a four-item issue that is 75% done and whose remaining
quarter is an admin action plus an unrelated codegen bug, each of which is now tracked
where someone can act on it.Full working notes, including every command run and its output:
gc-handoff/7737-NOTES.md.- Promote
Follow-ups from the audit of #7733 (merged as v0.5.1427). None blocked the merge; all four are cheap and concrete.
1.
OBJECT_PROTOTYPES_NONEMPTYis a one-way latch — the #7510 shape, with a third victimobject_static_prototype_owner_moved's new fast path is gated onOBJECT_PROTOTYPES_NONEMPTY.load(Acquire)(object/prototype_chain.rs:~340). Nothing ever resets that latch tofalse— includingprune_dead_object_prototype_ownerswhen itsretain()empties the map back out (prototype_chain.rs:328-335).So a single
Object.setPrototypeOfagainst a non-meta-capable owner anywhere in a process's lifetime — even one that later dies and gets pruned — permanently disables the fast path for the rest of that process. A long-running server that does one incidental re-prototyping early (of aRegExp, say) silently forfeits the 2.5 M-lock / 2.5 M-hash win, with no signal.This is pre-existing and shared by all three readers (
object_static_prototype:255,prune_dead_object_prototype_owners:329, and now the move hook), not introduced by #7733. But it is exactly #7510's finding — "one immortal side-table entry nullified everyis_empty()fast path" — recurring. Fix: reset the latch when prune leaves the map empty, or replace it with a real emptiness check.2. No positive-direction test for the recording
declining_to_escalate_records_no_pre_full_readingproves only the negative: a declined escalation leavespre_in_use == 0. Nothing asserts that atrueverdict from the realarena_growth_full_escalation_due()— not thetest_note_full_cycle_reclaimedbypass — leaves a non-zero reading behind.That positive direction is precisely the shape of the first-cut bug #7733's own changelog describes: recording wired at the wrong call sites,
update_major_pacing_backoffsilently no-op'ing, every test green. The gap exists because forcingdue=truedeterministically needs control overarena_in_use_bytes(), which isn't mockable today — real, but addressable.3. The grow-then-churn transition is named but unmeasured
Array-growth forwarding stubs are reclaimable only by a full mark-sweep (
arena_growth_full_escalation_due's own doc), andold_reclaim_pressure_duetracks old-gen occupancy, not stub bytes — so it does not substitute for that path.The scenario: a warm/cache-build phase with a growing all-live structure drives the shift to cap, then the workload transitions to steady array-churn that needs a full to reclaim stubs but never regrows arena in-use past the old 2× threshold. That reclaim is delayed until 8×, i.e. up to 4× more transient RSS for that shape, self-healing after one full once it fires.
Bounded, not unbounded — but neither
retain.ts(pure growth) nortree.ts(always high-yield) exercises it. Both are extremes; the transition between them is the untested middle. Agc_ratchetprobe that grows without garbage long enough to hit the cap, then switches to churn, asserting a peak-RSS bound, would close it.4.
gc-ratchetandgc-stressare not required contextsConfirmed against branch protection — required contexts are
lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit, conformance-smoke-complete. Bothgc-ratchetandgc-stressrun and can report red without blocking a merge.That matters for #7733 specifically:
12_large_live_set'sheap_used_bytes/heap_total_bytescells (direction: "increase") are the gate that would catch a footprint regression from the backoff, and #7733's central quantitative claim rests on them. Advisory-only coverage of the exact risk the change carries.This is CLAUDE.md's own "four ways a gate can be unable to fail", #2 — and its
gc-stressexample is the precedent. Promotion is a maintainer action; per the same doc a new gate should be observed green onmainonce first, which recent runs now satisfy.