Skip to content

fix(test-isolation): the GC guards' clear can no longer reach another test's data (#7672) - #7674

Merged
proggeramlug merged 9 commits into
mainfrom
fix/7672-global-sink-isolation
Aug 9, 2026
Merged

fix(test-isolation): the GC guards' clear can no longer reach another test's data (#7672)#7674
proggeramlug merged 9 commits into
mainfrom
fix/7672-global-sink-isolation

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #7672.

The class, and why the three fixes so far did not close it

gc::tests::support::reset_copying_nursery_runtime_test_state() runs from GcTestIsolationGuard and CopyingNurseryTestGuard, on whatever libtest thread happens to construct one, and calls 20 test_clear_* helpers that empty process-global side tables. The guards serialize against each other, and against the handful of tests that remember to take crate::gc::global_side_table_test_lock(). Nothing requires a reader to take it, so the defence is opt-in and the opt-in is invisible at the read site.

fixed in table presented as
#7665 opt_report's row sink rows.len() == 2 failing at 3
#7665 ext_registry's USED_PROVIDERS "empty" failing with ioredis present
#7671 closure's CLOSURE_PROPS a static method read back as TAG_UNDEFINED

Each was exposed by an unrelated PR that changed the parallel schedule, never introduced by it, so the exposing author paid the diagnosis and the natural conclusion ("my change broke something") was wrong.

What this changes: storage, not locking

A lock cannot close this. The damage window is "between this test's write and this test's read", and only the test knows that span. An accessor that takes a shared lock for one call does not cover it. A lock that covers it has to be taken by the test — which is the opt-in the class is made of, on a reader population the issue counts at ~180 and growing.

So the storage moves. per_test_global! (crates/perry-runtime/src/per_test_global.rs) expands to:

  • outside a test build — the plain static it replaced, byte for byte. test_clear_* is #[cfg(test)] and never runs in a shipped runtime, so the hazard and the fix are both confined to tests.
  • inside a test build — a PerThread<T> that derefs to a per-thread instance. libtest runs one thread per test, so "per thread" and "per test" coincide, and a guard on thread U empties U's instance, which already holds only U's entries.

That is exactly the isolation the clear was reaching for. Call sites do not changeSYMBOL_REGISTRY.lock() and CLASS_PROTOTYPE_OBJECTS.read() keep working through Deref, so this is a declaration-site change and not a 300-site rewrite.

37 statics converted across 13 files (26 at first, plus the class-registry and soak-found ones), covering every family the guards clear: closure dynamic props (3), symbol side tables (5), the class-registry RwLocks (7), the timer queues (3), the object-constant caches and GLOBAL_THIS_PTR (9), geisterhand (2), ui_text_registry (2), the console.log singleton.

What holds the line, and how each half is shown able to fail

scripts/global_sink_isolation.py, in lint (a required context; ~1s, no compiler). It derives the clear list from reset_copying_nursery_runtime_test_state's own source, follows one level of same-file accessors (test_clear_closure_side_tables names no static at all — it goes through get_closure_props()), resolves each identifier in its own module first, and classifies the storage as thread_local! / per_test_global! / Mutex<()> lock / bare static. A bare static fails unless allowlisted with an issue, and an allowlist entry that matches nothing also fails. The burden lands on the ~20 table authors, who are finite and gated, instead of on the readers, who are not.

Its --self-test has 9 checks: a bare table is rejected, a converted one and a thread-local one are not, allowlisting silences exactly one entry, a stale entry fails, a renamed or empty reset function raises instead of passing vacuously, and the parsers are run against the real tree (≥10 helpers parsed, CLOSURE_PROPS classified per_test, ARGUMENTS_OBJECTS classified thread_local) so a regex that stops matching cannot make the gate green.

gc::tests::global_sink_isolation, 9 tests. Each plants the #7671 shape — write on this thread, run the guards' real clear on another thread, read back — for one test_clear_* helper. They deliberately do not take the global lock: taking it would test the opt-in rather than the isolation. Every probe asserts its subject was live before the clear, so "survived" is never confused with "was never installed".

  • the_probe_catches_a_bare_process_global_sink runs the identical procedure against a canary declared as a plain static and requires the wipe to be observed. A green file means the detector works, not that nothing was tried.
  • every_covered_clear_helper_is_still_called_by_the_guards include_str!s support.rs at compile time so a probe for a helper that no longer runs cannot sit green forever.

Sabotage, measured, not asserted. With the macro's #[cfg(test)] arm reverted to the pre-#7672 bare static — one edit, every table at once — 7 of 9 tests fail, each with the message naming the cause:

closure_side_tables_survive_a_guard_clear_on_another_thread ... FAILED
symbol_side_tables_survive_a_guard_clear_on_another_thread ... FAILED
timer_queues_survive_a_guard_clear_on_another_thread ... FAILED
class_registry_tables_survive_a_guard_clear_on_another_thread ... FAILED
object_cache_roots_survive_a_guard_clear_on_another_thread ... FAILED
geisterhand_registry_survives_a_guard_clear_on_another_thread ... FAILED
ui_text_registry_survives_a_guard_clear_on_another_thread ... FAILED
test result: FAILED. 2 passed; 7 failed

(error[ count 0 and Running unittests present on that run — the sabotage compiled and executed rather than failing to build.)

Allowlist: 4 entries, each with a reason

Validation

  • cargo test -p perry-runtime --lib --no-fail-fast25 consecutive runs, 25 green, 0 vacuous (each run checked for Running unittests and for error[ before its result was counted). 1926 passed / 0 failed / 4 ignored, ~8.5 s per run; the conversion costs no measurable wall time.
  • cargo check --all-targets — clean.
  • All 24 lint gate commands green, including the new one.
  • rustup run stable cargo fmt --all -- --check — clean.

timer.rs sat three lines under the 2000-line cap, so the macro takes an optional trailing ; and the three timer tables are declared on one line each — the file is unchanged at 1997 lines.

Also found while surveying (not fixed here)

The same architecture exists outside the guards' clear list, with the same split-lock-domain signature:

  • async_hooks HOOKS / RESOURCES / NEXT_ASYNC_ID sit under four disjoint serialization domains, and gc/tests/alloc.rs:836 takes none of them. resource_ids_are_monotonic_even_without_hooks asserts b.async_id == a.async_id + 1 — a wrong-VALUE flake waiting for a schedule change.
  • tui::state::SLOTS is cleared under three different locks; alloc_returns_sequential_handles asserts h0 == 0, h1 == 1, h2 == 2.
  • agent_dispatch_tests.rs's private TIMER_QUEUE_TESTS lock protects the timer queues the guards also clear — the opt_report / ext_registry shape exactly.

These are candidates for the same treatment; they are called out rather than folded in because none of them is on the guards' clear path, which is what this PR's gate covers.


Update: two more instances, found by soaking this very fix

The first 25-run soak was 25/25 green. A second, 22-run soak produced two reds — which is exactly why the ≥20-run bar exists, and why the first all-clear was not one. Both are the same class, neither is reached by any test_clear_* helper, and both were diagnosed from the wrong VALUE rather than the timing:

  • gc::barrier::GENERATED_WRITE_BARRIERS_EMITTED — owned by two guards under two different locks (CopyingNurseryTestGuard under the copying-nursery isolation lock, GeneratedWriteBarrierTestGuard under GENERATED_BARRIER_TEST_LOCK) and read by every runtime write barrier holding neither. sabotaged_parent_gate_strands_a_young_child_the_shipped_gate_keeps failed with missing_edges=1 ... slot_page_ever_dirty=false — the barrier did not fire, because another thread's GeneratedWriteBarrierTestGuard::inactive() had zeroed the flag mid-test.
  • tui::tree's NEXT_HANDLE / REGISTRY — no clear, no lock, and register_increments_handle asserts h2 == h1 + 1 plus an exact registry length.

Both are converted, both have a dedicated regression test, and both fail under the same one-edit sabotage (now 9 of 11 tests red).

The macro is renamed per_test_global! — for what it does, not for the sharpest instance of what it defends against, since two residents are cleared by nothing. Naming it after the clear would have made these two look like misfits rather than the same defect. The gate now audits the guards' own module alongside the clear list, which is what makes the write-barrier flag visible to it at all.

Two ways this gate could itself have gone quiet — both closed, both self-tested

  • The per_test_global!(...) paren form (used in timer.rs to stay under the 2000-line cap) was invisible to a {-only matcher, silently taking the three timer tables to (no static storage). A gate that stops matching reports zero hazards and exits 0 — indistinguishable from a clean tree. Reverting the delimiter fix now fails a self-test case by name.
  • A classified-statics floor (93 today, floor 60) fires when the matchers rot, instead of reporting clean.
  • A Mutex<()> serializer is classified as a lock, never a hazard: making one per-thread would turn it into a no-op — the opposite of the fix.

Self-test is now 15 checks, allowlist 5 entries each with a reason.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0363b378-0220-428f-b06a-7c5dde4dd031

📥 Commits

Reviewing files that changed from the base of the PR and between 43154ce and 7993f67.

📒 Files selected for processing (19)
  • .github/workflows/test.yml
  • changelog.d/7674-per-test-global-sinks.md
  • crates/perry-runtime/src/builtins/console.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/tests/global_sink_isolation.rs
  • crates/perry-runtime/src/geisterhand_registry.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/class_registry/prototype_objects.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/per_test_global.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/symbol/accessors.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/tui/tree.rs
  • crates/perry-runtime/src/ui_text_registry.rs
  • scripts/global_sink_isolation.py
📝 Walkthrough

Walkthrough

The runtime adds test-only per-thread storage for GC-cleared global side tables. Runtime registries use the new declaration macro. Cross-thread isolation tests and a source audit validate coverage. CI runs both checks.

Changes

Global sink isolation

Layer / File(s) Summary
Per-thread storage and macro
crates/perry-runtime/src/guard_cleared_global.rs, crates/perry-runtime/src/lib.rs
Adds PerThread<T> and guard_cleared_global!. Test builds use per-thread tables. Production builds retain process-global statics.
Runtime global migration
crates/perry-runtime/src/builtins/console.rs, crates/perry-runtime/src/closure/..., crates/perry-runtime/src/geisterhand_registry.rs, crates/perry-runtime/src/object/..., crates/perry-runtime/src/symbol/..., crates/perry-runtime/src/timer.rs, crates/perry-runtime/src/ui_text_registry.rs
Wraps runtime side tables, registries, queues, caches, roots, and counters with guard_cleared_global!. Types, initializers, and call sites remain unchanged.
Foreign-clear isolation tests
crates/perry-runtime/src/gc/tests/global_sink_isolation.rs, crates/perry-runtime/src/gc/tests/mod.rs, crates/perry-runtime/src/gc/tests/support.rs
Adds cross-thread probes for closure, symbol, timer, class, object-cache, geisterhand, and UI-text state. A bare-global canary verifies detection of unconverted storage.
Static audit and CI gate
scripts/global_sink_isolation.py, .github/workflows/test.yml, changelog.d/7674-guard-cleared-global-sinks.md
Adds source parsing, storage classification, allowlist validation, self-tests, CLI exit handling, and a required CI lint step. The changelog records the implementation and validation.
Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: type:bug

Suggested reviewers: thehypnoo

Sequence Diagram(s)

sequenceDiagram
  participant TestThread
  participant RuntimeTable
  participant ClearThread
  participant GuardReset
  TestThread->>RuntimeTable: install test state
  ClearThread->>GuardReset: reset GC test state
  GuardReset->>RuntimeTable: clear ClearThread table
  TestThread->>RuntimeTable: verify state survives
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #7672 by isolating guard-cleared state per test thread and adding enforcement for future unsafe globals.
Out of Scope Changes check ✅ Passed The implementation, tests, lint audit, documentation, and changelog all support the linked test-isolation objective.
Title check ✅ Passed The title clearly identifies the test-isolation fix: GC guard clears no longer reach another test's data.
Description check ✅ Passed The description clearly covers the issue, implementation, affected areas, related issue, tests, validation, and known follow-up risks.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7672-global-sink-isolation

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: 4

🤖 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 `@changelog.d/7674-guard-cleared-global-sinks.md`:
- Around line 37-40: Reconcile the “23 tables converted” headline with the
category counts in the listed breakdown, which sum to 32. Update either the
headline or the individual category counts so the documented total and breakdown
agree.

In `@crates/perry-runtime/src/gc/tests/global_sink_isolation.rs`:
- Around line 293-301: Update the coverage assertions around
COVERED_CLEAR_HELPERS to include the console-reset isolation probe before
validating helper coverage, since reset_copying_nursery_runtime_test_state
already invokes builtins::test_set_console_log_singleton(0). Adjust the
assertion wording or filtering so it covers this non-test_clear helper without
claiming the check is limited to test_clear_* helpers.

In `@crates/perry-runtime/src/gc/tests/support.rs`:
- Line 355: Keep reset_copying_nursery_runtime_test_state private and add a
sibling-visible wrapper that acquires copying_nursery_isolation_lock() before
invoking it. Update foreign_guard_clear to call the locked wrapper, while
ensuring the probe thread remains unlocked before spawning so it continues
testing an unlocked reader.

In `@scripts/global_sink_isolation.py`:
- Around line 196-205: Update the helper expansion around find_fn_body to
recursively follow same-file helper calls until no new bodies are found,
tracking visited function names to prevent cycles and retaining the existing
accessor filtering as appropriate. Ensure identifiers from every reachable
wrapper are included in idents, and add a self-test covering BARE_TABLE accessed
through a two-hop wrapper.
🪄 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: aebe5c2c-d9f9-462b-9fac-da6c6160fbaa

📥 Commits

Reviewing files that changed from the base of the PR and between a853135 and 43154ce.

📒 Files selected for processing (19)
  • .github/workflows/test.yml
  • changelog.d/7674-guard-cleared-global-sinks.md
  • crates/perry-runtime/src/builtins/console.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/gc/tests/global_sink_isolation.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/support.rs
  • crates/perry-runtime/src/geisterhand_registry.rs
  • crates/perry-runtime/src/guard_cleared_global.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/class_registry/prototype_objects.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/symbol/accessors.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/ui_text_registry.rs
  • scripts/global_sink_isolation.py

Comment thread changelog.d/7674-per-test-global-sinks.md Outdated
Comment on lines +293 to +301
const COVERED_CLEAR_HELPERS: &[&str] = &[
"test_clear_closure_side_tables",
"test_clear_symbol_side_table_roots",
"test_clear_all_timer_scanner_roots",
"test_clear_class_side_table_roots",
"test_clear_object_cache_roots",
"test_clear_geisterhand_roots",
"test_clear_ui_text_registry_roots",
];

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/perry-runtime/src/builtins/console.rs --items all
rg -n -C 4 'test_set_console_log_singleton|test_.*console|CONSOLE.*SINGLETON|console.*singleton' \
  crates/perry-runtime/src/builtins/console.rs \
  crates/perry-runtime/src/gc/tests

Repository: PerryTS/perry

Length of output: 22422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the coverage list and the reset helper it is meant to cover.
sed -n '260,305p' crates/perry-runtime/src/gc/tests/global_sink_isolation.rs
sed -n '340,390p' crates/perry-runtime/src/gc/tests/support.rs

# Check whether global_sink_isolation test has a console-related foreign-clear test
rg -n -C 4 'console|console_log_singleton|COVERED_CLEAR_HELPERS|covered_clear' crates/perry-runtime/src/gc/tests/global_sink_isolation.rs crates/perry-runtime/src/gc/tests/support.rs

Repository: PerryTS/perry

Length of output: 8024


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the actual probe implementation for covered clear helpers.
fd -a 'global_sink_isolation.rs|support.rs' crates/perry-runtime/src/gc/tests | sed 's#^\./##'
rg -n 'survives_a_foreign_clear|test_clear_|FOREIGN_CLEAR|covered' crates/perry-runtime/src/gc/tests/global_sink_isolation.rs crates/perry-runtime/src/gc/tests/support.rs

Repository: PerryTS/perry

Length of output: 5298


Move the console-reset isolation check into the covered helper assertions.

COVERED_CLEAR_HELPERS now documents the helpers this file claims to cover, but builtins::test_set_console_log_singleton(0) is already called by reset_copying_nursery_runtime_test_state. Add the console probe before asserting it and adjust the check text if it is no longer limited to test_clear_* helpers.

🤖 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/global_sink_isolation.rs` around lines 293
- 301, Update the coverage assertions around COVERED_CLEAR_HELPERS to include
the console-reset isolation probe before validating helper coverage, since
reset_copying_nursery_runtime_test_state already invokes
builtins::test_set_console_log_singleton(0). Adjust the assertion wording or
filtering so it covers this non-test_clear helper without claiming the check is
limited to test_clear_* helpers.

}

fn reset_copying_nursery_runtime_test_state() {
pub(super) fn reset_copying_nursery_runtime_test_state() {

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

Keep the reset lock precondition inside the exported API.

This function assumes that the caller owns copying_nursery_isolation_lock(). crates/perry-runtime/src/gc/tests/global_sink_isolation.rs calls it from foreign_guard_clear without that lock. The direct call can race a real guard while resetting process-global test state.

Keep this reset function private. Expose a sibling-visible wrapper that takes the same lock before it calls this function. Use that wrapper from foreign_guard_clear. The probe thread must not take the lock before spawning, so the test still exercises an unlocked reader.

Proposed fix
-fn reset_copying_nursery_runtime_test_state() {
+fn reset_copying_nursery_runtime_test_state() {
     // existing reset body
 }
+
+pub(super) fn reset_copying_nursery_runtime_test_state_with_lock() {
+    let _lock = copying_nursery_isolation_lock();
+    reset_copying_nursery_runtime_test_state();
+}

Based on supplied test context, foreign_guard_clear invokes this function without the guard lock.

🤖 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/support.rs` at line 355, Keep
reset_copying_nursery_runtime_test_state private and add a sibling-visible
wrapper that acquires copying_nursery_isolation_lock() before invoking it.
Update foreign_guard_clear to call the locked wrapper, while ensuring the probe
thread remains unlocked before spawning so it continues testing an unlocked
reader.

Comment thread scripts/global_sink_isolation.py
Ralph Küpper added 8 commits August 9, 2026 08:16
…the macro is renamed for what it does (#7672)

A 22-run soak of the first fix produced two reds, both the same class and
both diagnosed from the wrong VALUE rather than the timing:

  * `gc::barrier::GENERATED_WRITE_BARRIERS_EMITTED` is owned by TWO guards
    under TWO DIFFERENT locks and read by tests holding neither, so
    `GeneratedWriteBarrierTestGuard::inactive()` on one thread silences
    another thread's runtime barrier. `sabotaged_parent_gate_strands_a_
    young_child_the_shipped_gate_keeps` failed with
    `missing_edges=1 ... slot_page_ever_dirty=false` — the barrier did not
    fire.
  * `tui::tree`'s `NEXT_HANDLE` / `REGISTRY` have no clear and no lock, and
    `register_increments_handle` asserts `h2 == h1 + 1`.

Neither is reached by a `test_clear_*` helper, so the macro is renamed
`per_test_global!` for what it does rather than for the sharpest instance
of the hazard, and the lint gate now audits the guards' own module as well
as the clear list.

Two ways the gate could have gone quiet, both closed and both self-tested:
the `per_test_global!(...)` paren form was invisible to a `{`-only matcher
(silently taking the three timer tables to "no static storage"), and a
matcher that stops matching now trips a classified-statics floor instead of
reporting a clean tree. A `Mutex<()>` serializer is classified as a lock,
never a hazard — making one per-thread would turn it into a no-op.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug
proggeramlug force-pushed the fix/7672-global-sink-isolation branch from 61cf886 to 7993f67 Compare August 9, 2026 06:18
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1387

You were right and my recommendation was wrong, for the reason you gave: "readers get a cheap assertion" cannot satisfy my own second requirement, because the assertion is an opt-in on the same unbounded reader population. I proposed detection where prevention was available. Partitioning the storage removes the class; observing the clear only tells you after the fact, and only for readers who remembered to ask.

The three properties that make it the right shape:

  • Production is unchanged by construction, not by argument — the #[cfg(not(test))] arm expands to $vis static $name: $ty = $init;, literally the plain static. I built -p perry-runtime-static -p perry-stdlib-static to confirm the shipped path still links.
  • Zero call-site churn. Deref means SYMBOL_REGISTRY.lock() and CLASS_PROTOTYPE_OBJECTS.read() keep working across all 37 statics in 13 files.
  • The burden inverts: ~180 readers who must remember an opt-in become ~20 table authors, and global_sink_isolation.py derives the clear list from the guards' own source and fails on a bare static. I ran it: 0 hazards, 5 allowlisted, 93 statics classified.

Sabotage verified independently: reverting the cfg(test) arm to a plain static reddens 8 tests, error[ 0, Running unittests present, and 1928 green on restore.

Finding five instances, two of them by soaking your own fix

GENERATED_WRITE_BARRIERS_EMITTED is the one that matters most — two guards, two locks, read by neither, presenting as slot_page_ever_dirty=false, i.e. the barrier silently not firing. That is not a test-hygiene bug wearing a correctness costume; it is the reverse.

The intermittency evidence is the part I'd hold up as the model

25/25 green — "a false all-clear" — then 20/22, which is what found the two extra instances. Naming the first soak as a false all-clear rather than banking it is the whole discipline. ~102 runs across four soaks.

And the honesty on #7683: 1 in ~102 on the branch vs 0 in 25 on main does not distinguish at that rate, and you say so. What carries it is that every frame is in code this PR does not touch. That is a correctly-scoped claim.

Two more

#7018 closed by refutation — doesn't reproduce in 20 runs, and structurally cannot: PERRY_GC_TRACE never dereferences a heap object; the issue describes gc/trace.rs, the marking tracer. A refuted issue closed with evidence is worth as much as a fixed one.

All four arms in gc_matrix_inert_arms.txt were live, and CI's gc-stress on main has been red for it at 41/58 cells with four STALE-REGISTRY lines. Unasked-for and exactly right to fix.

Your harness nearly lying twice — counting an external SIGTERM as a product flake, then SIGSEGV as an external kill — and your lint gate silently losing three tables to the per_test_global!(...) paren form are both worth the record. The delimiter-agnostic re-do with a 60-static floor and both-way self-test is the fix.

Gates: 24/24 lint (the extraction is 24 now, up from 22), fmt clean, perry-runtime --lib 1928, perry-codegen --lib 778, cargo check --all-targets clean, global_sink_isolation.py self-test and gate both clean.

@proggeramlug
proggeramlug merged commit 5296e3c into main Aug 9, 2026
@proggeramlug
proggeramlug deleted the fix/7672-global-sink-isolation branch August 9, 2026 06:29
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.

Process-global side tables are cleared by test guards that readers are not required to take — three flakes in two days

1 participant