Skip to content

test(runtime): dead_owner_side_tables was measuring the globalThis bootstrap - #7987

Merged
proggeramlug merged 2 commits into
mainfrom
test/7975-dead-owner-side-tables-realm-bootstrap
Aug 12, 2026
Merged

test(runtime): dead_owner_side_tables was measuring the globalThis bootstrap#7987
proggeramlug merged 2 commits into
mainfrom
test/7975-dead-owner-side-tables-realm-bootstrap

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7975.

It is not a race

Every test in gc::tests::dead_owner_side_tables already takes the same global
isolation mutex, so no two of them ever overlap. Run on their own, the two
named cases fail 200/200:

binary config runs failed
origin/main @ b847afd1c --test-threads=10 dead_owner_side_tables 200 10
origin/main --test-threads=1 dead_owner_side_tables 200 0
origin/main test_dead_arguments_object_entry_pruned_on_full_gc ALONE 200 200
origin/main test_dead_owner_descriptor_entries_pruned_on_full_gc ALONE 200 200

They pass only because a sibling ran first. --test-threads=10 makes which
test goes first nondeterministic, so ~5–7 % of filtered runs put one of them
there.

Mechanism — two facts and a confounder

1. A process-global one-shot decides which THREAD pays for the realm. Both
cases reach an API that resolves the process-global memoized prototype address
(array::prototype_addr::{ARRAY_PROTO_ADDR, OBJECT_PROTO_ADDR}), and a miss runs
the whole lazy globalThis bootstrap — ~1.15 MB allocated, ~410 KB live, rooted
for the life of the thread — inside the caller:

test_dead_owner_descriptor_entries_pruned_on_full_gc
  → object::descriptor_state::set_property_attrs
    → array::prototype_addr::resolve_prototype_addr
      → js_get_global_this_builtin_value → js_get_global_this
        → object::global_this::populate::populate_global_this_builtins

test_dead_arguments_object_entry_pruned_on_full_gc
  → js_arguments_object_alloc → js_object_set_field_by_name → (same)

The bootstrap is per-thread (THREAD_GLOBAL_THIS) but the cache is
per-process, so once any libtest thread fills it no later thread ever asks for
globalThis.

2. Block persistence force-marks the whole block. Arena block reset is
all-or-nothing, so gc::trace::mark_block_persisting_arena_objects
(BLOCK_PERSIST_WINDOW = 5) marks every object in a block that holds one
reachable object.

Confounder. With the bootstrap co-resident, the test's unrooted owner is
force-marked, dead_owner::PostTraceProbe::owner_is_dead returns false, and the
entry is correctly not pruned — a persisting block cannot recycle the
address, so the entry is not stale. The test then fails on the prune and blames
the prune. Instrumented, in the same case:

bootstrap INSIDE the test  → owner flags = ARENA|MARKED, eden live_pct=100, 3588 probed owners all marked
bootstrap done ELSEWHERE   → owner flags = ARENA,        eden live_pct=0

Ruled out: conservative stack scan (PERRY_CONSERVATIVE_STACK_SCAN=off and
=on still fail), force_full_scan fallback (no [gc-scan-fallback] line),
root-scanner leakage (root_scanner_registry_counts() is (0,0,0,0) in both).

The change

  • GcTestIsolationGuard::with_realm_bootstrapped() — runs the bootstrap inside
    the isolation lock but before ScopedRootScannerRegistryGuard takes the
    thread's scanners and before reset_global_roots(), so the realm graph is
    outside the measured window and cannot keep the test's block alive.
  • gc::trace::block_persist_force_mark_count() — an always-on, O(1)-per-pass
    thread-local census of block-persistence force-marks, recorded by both the
    whole-cycle pass and the budgeted BlockPersistCycleState arm. Same rationale
    as gc::scan_fallback (gc: Track A2's premise is wrong — six force_full_scan() sites, four automatic, and PERRY_CONSERVATIVE_STACK_SCAN=full fails 134 runtime tests #7148): the mark is cleared again by the sweep, so
    before this there was no observable a test could use to state its own premise.
  • full_gc_with_no_block_persistence() fails as a premise rather than
    letting the subject assertion mis-name it. Sabotage-checked: reverting only the
    guard turns both cases from the old misleading
    "…entry must be pruned" into
    "test premise: block persistence force-marked objects during this collection…
    left: 4083, right: 0"
    .
  • test_block_persistence_census_moves_when_a_block_has_a_live_tenant() plants
    the confounder's exact shape (one rooted object + one unrooted owner in one
    block, no realm involved) and asserts both halves — the census moves, and
    the force-marked owner's side-table entry correctly survives. Without it every
    "premise held" verdict would be vacuous.

No assertion was weakened.

Verification

binary config runs failed
fixed --test-threads=10 dead_owner_side_tables 400 0
fixed args case ALONE 200 0
fixed descr case ALONE 200 0
fixed new census case ALONE 200 0
fixed FULL suite, default parallelism 300 0
origin/main FULL suite, default parallelism 300 0 (unchanged)

cargo fmt --all -- --check and scripts/check_file_size.sh clean.

Not fixed here — a product defect this was sitting on

ARRAY_PROTO_ADDR / OBJECT_PROTO_ADDR are process-global AtomicUsize holding
raw addresses of objects in a thread-local arena, while the globalThis
realm they name is per-thread. The first thread to touch Array.prototype /
Object.prototype decides the value for every other agent in the process. Filed
separately; same family as #7955.

Summary by CodeRabbit

  • Bug Fixes

    • Improved garbage collection handling for objects sharing persistent memory blocks.
    • Fixed test isolation around lazy realm initialization to prevent misleading collection results.
    • Added regression coverage for rooted and unrooted objects sharing an arena block.
  • Tests

    • Added safeguards that detect unexpected force-marking during full garbage collection.
    • Added repeated-run validation for dead-owner cleanup scenarios.

…otstrap

`test_dead_arguments_object_entry_pruned_on_full_gc` and
`test_dead_owner_descriptor_entries_pruned_on_full_gc` fail 200/200 when they
are the first test in the process and 0/200 when any sibling runs first — 10 in
200 `--test-threads=10` runs of the module. It is not a race: every test in the
module already takes the same global isolation mutex.

Both reach the runtime through an API that resolves the process-global memoized
`Object.prototype` address (`array::prototype_addr`), and a miss runs the whole
lazy `globalThis` bootstrap — ~1.15 MB allocated, ~410 KB live — inside the
caller. Arena block reset is all-or-nothing, so
`mark_block_persisting_arena_objects` then force-marks every object in the
block, the test's unrooted owner included; the death prune correctly declines to
drop an entry whose address cannot be recycled.

`GcTestIsolationGuard::with_realm_bootstrapped()` runs the bootstrap inside the
isolation lock but before the scanner registry is taken and the roots are reset,
so the realm graph is outside the measured window.

The mark is cleared again by the sweep, so a test had no way to state that
premise. `gc::trace::block_persist_force_mark_count()` is an always-on,
O(1)-per-pass census of block-persistence force-marks (same rationale as
`gc::scan_fallback`, #7148), recorded by both the whole-cycle and the budgeted
arm. `full_gc_with_no_block_persistence()` fails as a premise instead of
letting the subject assertion mis-name it, and
`test_block_persistence_census_moves_when_a_block_has_a_live_tenant` plants the
confounder's exact shape so the census is sabotage-tested rather than merely
read.

Closes #7975
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ae12069-b58c-470a-b617-b611da7da1a2

📥 Commits

Reviewing files that changed from the base of the PR and between bdfcba4 and 423ad1e.

📒 Files selected for processing (5)
  • changelog.d/7987-dead-owner-side-tables-realm-bootstrap.md
  • crates/perry-runtime/src/gc/cycle.rs
  • crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs
  • crates/perry-runtime/src/gc/tests/support.rs
  • crates/perry-runtime/src/gc/trace.rs

📝 Walkthrough

Walkthrough

The changes isolate lazy realm bootstrap work from GC measurements, add cumulative tracking for block-persistence force marks, and update dead-owner side-table tests with premise checks and shared-block regression coverage.

Changes

Dead-owner GC test isolation

Layer / File(s) Summary
Force-mark census instrumentation
crates/perry-runtime/src/gc/trace.rs, crates/perry-runtime/src/gc/cycle.rs
Block-persistence passes now record cumulative counts of newly force-marked objects.
Realm bootstrap isolation guard
crates/perry-runtime/src/gc/tests/support.rs
GcTestIsolationGuard adds with_realm_bootstrapped() and initializes globalThis before the isolation window.
Guarded dead-owner regression coverage
crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs, changelog.d/7987-dead-owner-side-tables-realm-bootstrap.md
Dead-owner tests reject force-marking during full GC, use explicit realm bootstrapping, and cover rooted and unrooted objects sharing an arena block. The changelog records the diagnosis and test results.

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

Sequence Diagram(s)

sequenceDiagram
  participant DeadOwnerSideTableTest
  participant GcTestIsolationGuard
  participant globalThis
  participant FullGC
  participant SideTable

  DeadOwnerSideTableTest->>GcTestIsolationGuard: bootstrap realm and isolate roots
  GcTestIsolationGuard->>globalThis: initialize globalThis
  globalThis-->>GcTestIsolationGuard: return pointer
  DeadOwnerSideTableTest->>FullGC: run guarded full collection
  FullGC-->>DeadOwnerSideTableTest: return force-mark census
  DeadOwnerSideTableTest->>SideTable: verify dead-owner entry is pruned
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7249: Both changes modify lazy globalThis bootstrap handling and GC test isolation.
  • PerryTS/perry#7809: Both changes involve globalThis bootstrap handling in GC tests, but this PR adds force-mark and persistence checks.

Suggested reviewers: thehypnoo

✨ 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 test/7975-dead-owner-side-tables-realm-bootstrap

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.

@proggeramlug
proggeramlug marked this pull request as ready for review August 12, 2026 18:52
@proggeramlug
proggeramlug merged commit 23a8aad into main Aug 12, 2026
0 of 18 checks passed
@proggeramlug
proggeramlug deleted the test/7975-dead-owner-side-tables-realm-bootstrap branch August 12, 2026 18:54
proggeramlug pushed a commit that referenced this pull request Aug 12, 2026
`main` is currently RED on the `lint` job's `check_thread_locals.py` step, and
has been since #7987 (`23a8aad31`): it added `BLOCK_PERSIST_FORCE_MARKS` to
`crates/perry-runtime/src/gc/trace.rs` — a genuinely cold declaration, updated
once per fixed-point round, never per object — without re-recording the file's
count, so the ratchet reads "2 recorded, 3 found" and fails.

Unrelated to the rest of this PR and deliberately its own commit, but the gate
is a REQUIRED context, so nothing can go green until it is re-recorded.
`--self-test` still passes in all six directions.

`_hot_declarations` also moves 163 -> 174; that field is informational (the
gate is the raw-block ratchet and the 768-slot capacity), and it had drifted
independently of this PR's one new `perry_thread_local!`.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
proggeramlug added a commit that referenced this pull request Aug 12, 2026
#7994)

* fix(gc): give each thread its own memoized prototype addresses (#7988)

`ARRAY_PROTO_ADDR` / `OBJECT_PROTO_ADDR` were process-global `AtomicUsize`
statics holding RAW ADDRESSES of objects in a thread-local arena, while the
realm they name is per-thread: `js_get_global_this` bootstraps
`THREAD_GLOBAL_THIS` once per THREAD, but `resolve_prototype_addr` missed only
once per PROCESS. The first thread to touch either intrinsic decided the value
for every other `perry/thread` agent.

Three consequences, all closed structurally by per-thread storage:

1. Wrong identity — `object_prototype_addr_matches` on agent B compared B's
   objects against A's `Object.prototype`, so B's `Object.prototype[7] = v`
   never flipped `OBJECT_PROTO_HAS_INDEX` and B's `[1,2,3][7]` read undefined.
2. Unattributed dereference — `heal_prototype_addr` read the cached address's
   `GcHeader` on every indexed array write, on memory the reading thread had no
   claim to (A's collector may have swept or moved it; A's arena blocks are
   dealloc'd at A's exit).
3. Cross-thread root rewrite — `scan_prototype_addr_cache_roots_mut` wrote the
   collecting thread's to-space address into a cell naming another agent's heap.

The recorded objection ("a single relaxed atomic load", "Darwin has no
local-exec TLS") is stale: `crate::perry_thread_local!` puts the value's address
in this thread's `HotTls` cache, reached with an `mrs` plus two loads that LLVM
CSEs — not an out-of-line `_tlv_get_addr` call. Both intrinsics share ONE
declaration (an array indexed positionally against `PROTOTYPE_ADDR_BUILTINS`),
so a function that consults both pays one resolution, and the root scanner
iterates that array itself — "a cell an accessor reads that the collector never
rewrites" stays unrepresentable.

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

* test(7988): make the multi-agent probe discriminating, add notes + changeset

The first version of `test_issue_7988_thread_realm_prototype.ts` was VACUOUS:
its warm-up (`main[1] = 9`, `main[7]`) resolves neither memoized address, so
the spawned agent was simply the first thread to fill the shared cell and the
probe answered correctly on the unfixed runtime. Measured, not assumed — it
printed the expected string 5/5 against a pre-fix `libperry_runtime.a`.

The main thread now pollutes its OWN realm first (`Array.prototype[4]`,
`Object.prototype[5]`), which is what forces both addresses to be resolved and
memoized before any agent starts, and the agent asserts BOTH directions:

  * LEAK      — `[1,2,3][4]` inside the agent must not see the main realm's
                prototype index (pre-fix it read "mainArr", dereferencing a
                GcHeader in an arena the agent does not own);
  * BLINDNESS — the agent's own `Array.prototype[8] = v` must be visible to the
                agent's own reads (pre-fix "undefined").

Measured with one compiler and two `libperry_runtime.a` pairs, swapped via
PERRY_RUNTIME_DIR: pre-fix 5/5 `match: false` + `allMatch: false`, post-fix 5/5
`match: true` + `allMatch: true`.

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

* chore(lint): re-record the cold thread_local added by #7987

`main` is currently RED on the `lint` job's `check_thread_locals.py` step, and
has been since #7987 (`23a8aad31`): it added `BLOCK_PERSIST_FORCE_MARKS` to
`crates/perry-runtime/src/gc/trace.rs` — a genuinely cold declaration, updated
once per fixed-point round, never per object — without re-recording the file's
count, so the ratchet reads "2 recorded, 3 found" and fails.

Unrelated to the rest of this PR and deliberately its own commit, but the gate
is a REQUIRED context, so nothing can go green until it is re-recorded.
`--self-test` still passes in all six directions.

`_hot_declarations` also moves 163 -> 174; that field is informational (the
gate is the raw-block ratchet and the 768-slot capacity), and it had drifted
independently of this PR's one new `perry_thread_local!`.

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

* test(7988): gate the multi-agent probe on a stored expected output

`perry/thread` has no Node equivalent, so `run_parity_tests.sh` scored the
probe as a permanent `parity_fail` (output mismatch against Node's
ERR_MODULE_NOT_FOUND) — which is what its four siblings on `main` already do:
`parity_known_failures.py` reports all of
test_issue_{4449_thread_promise_void, 7302_thread_throws,
7769_thread_class_dispatch, 7981_thread_shape_stamp_parent} as unlisted
failures on macOS.

The harness already has the right mechanism for a Perry-only API — a stored
`test-parity/expected/<name>.txt`, compared against Perry's output and exit
code instead of against Node (the `filehandle-thread-*` /
`threaded-fd-semantics-*` files use it). Use it, so the probe is a gate that
runs rather than a failure that is tolerated.

Verified able to fail: the same file compiled against a pre-fix
`libperry_runtime.a` produces
`spawn agent: mainArr/undefined/undefined/obj1 match: false`, 5/5.
Output is deterministic (6/6 identical md5 over repeat runs; every
`parallelMap` element is the same string, so worker completion order is not
observable).

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

* docs(gc-handoff): record the #7988 measurement, sabotage and inventory

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

---------

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.

test(runtime): dead_owner_side_tables races with itself — 15 failures in 200 filtered runs on main

1 participant