Skip to content

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

@proggeramlug

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_NONEMPTY is a one-way latch — the #7510 shape, with a third victim

object_static_prototype_owner_moved's new fast path is gated on OBJECT_PROTOTYPES_NONEMPTY.load(Acquire) (object/prototype_chain.rs:~340). Nothing ever resets that latch to false — including prune_dead_object_prototype_owners when its retain() empties the map back out (prototype_chain.rs:328-335).

So a single Object.setPrototypeOf against 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 a RegExp, 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 every is_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_reading proves only the negative: a declined escalation leaves pre_in_use == 0. Nothing asserts that a true verdict from the real arena_growth_full_escalation_due() — not the test_note_full_cycle_reclaimed bypass — 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_backoff silently no-op'ing, every test green. The gap exists because forcing due=true deterministically needs control over arena_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), and old_reclaim_pressure_due tracks 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) nor tree.ts (always high-yield) exercises it. Both are extremes; the transition between them is the untested middle. A gc_ratchet probe that grows without garbage long enough to hit the cap, then switches to churn, asserting a peak-RSS bound, would close it.

4. gc-ratchet and gc-stress are not required contexts

Confirmed against branch protection — required contexts are lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit, conformance-smoke-complete. Both gc-ratchet and gc-stress run and can report red without blocking a merge.

That matters for #7733 specifically: 12_large_live_set's heap_used_bytes/heap_total_bytes cells (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-stress example is the precedent. Promotion is a maintainer action; per the same doc a new gate should be observed green on main once first, which recent runs now satisfy.

Activity

  1. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    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_NONEMPTY latch — DONE

    prototype_chain.rs now releases the latch when prune_dead_object_prototype_owners's retain() drains the map (:345, :357), and latches before the insert and under the lock (:222). Covered by mod 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:853 carries escalating_records_the_pre_full_arena_reading, labelled "#7737 item 2", asserting a true verdict from the real arena_growth_full_escalation_due() leaves a non-zero pre_in_use that equals the reading the predicate decided on. The "not mockable today" blocker was closed by a #[cfg(test)] seam in front of pacing_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.ts exists 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 (shipped MAJOR_PACING_BACKOFF_SHIFT_MAX = 2 vs 0) 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-stress are not required contexts — still open, and now blocked

    Confirmed 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 main once first, which recent runs now satisfy" — no longer holds. Both gates are currently RED on main:

    • gc-ratchet, last 30 main runs: 0 success, 13 failure, 15 cancelled. Most recent completed non-cancelled run (31526901962) ends gc-ratchet: FAILED.
    • gc-stress, last three completed main runs of test.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-ratchet main runs never executed. That is the concurrency/cancel-in-progress shape CLAUDE.md lists as the third way a gate cannot fail, and it makes "observed green on main once" 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-stress to required" issue with the green-first ordering spelled out), since a four-item issue that is 75% done reads as 100% open.

  2. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    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.

    1. 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 of main. The writer publishes under the registry lock before insertion, pruning clears the latch when retain() drains the map, and a_drained_prototype_registry_releases_the_fast_path_latch pins the regression.

    2. 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_reading calls the real arena_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.

    3. 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.ts records 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.

    4. 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, and conformance-smoke-complete; gc-ratchet and gc-stress are absent. That matches the current workflow source: gc-ratchet is fail-capable but not required, and test.yml still explicitly says gc-stress is 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 successful main run #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 add gc-ratchet and gc-stress to 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.

  3. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    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_NONEMPTY latch — DONE, and the test is discriminating

    Fix present: crates/perry-runtime/src/object/prototype_chain.rs:222 latches
    Release before the insert and under the registry mutex; :353-358
    prune_dead_object_prototype_owners clears the latch when map.is_empty().
    caaab6b98 (#7740) is an ancestor of 0d7fe21b0.

    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 process
    

    Restored, 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_NONEMPTY and get_object_prototypes() are referenced
    only in prototype_chain.rs — zero hits anywhere else under crates/. 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_reading and
    :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 of pacing_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 ............... ok
    

    That 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 in baseline/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=1 from 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-ratchet run 31831865551 (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: OK
    

    The 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; neither gc-ratchet nor gc-stress is among them.

    But the last triage's "promotion today would block every open PR" is now stale in
    the other direction
    . gc-ratchet has four consecutive green main runs
    (2026-08-14, on the four commits before 0d7fe21b0), 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 for gc-ratchet today.

    gc-stress is still red on main — eight consecutive nightly runs — but for exactly
    one reason, and it is not a GC reason: specabi_reassign fails in 22 of 22 arms of
    scripts/gc_repsel_matrix.sh, shipped_default included, while every other row is
    PASS or UNVER. I reproduced it locally at 0d7fe21b0 and reduced it to five lines —
    reassigning a new Int32Array binding to a plain array makes every element READ return
    0, because js_typed_array_get answers 0.0 on a clean_ta_ptr rejection instead of
    falling back to the ordinary [[Get]]. That is the exact mirror of #8090, and it is now
    #8100.

    Successors:

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions