Skip to content

test(runtime): make the --lib suite order-independent under default-parallel cargo test (#6965) - #7445

Merged
proggeramlug merged 2 commits into
mainfrom
fix/6965-webassembly-test-order
Aug 5, 2026
Merged

test(runtime): make the --lib suite order-independent under default-parallel cargo test (#6965)#7445
proggeramlug merged 2 commits into
mainfrom
fix/6965-webassembly-test-order

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #6965.

What was actually happening

namespace_members_exist_with_expected_shapes was not reading torn-down WebAssembly state — it was losing its constructor prototype entries out of the process-global CLOSURE_PROPS side table. install_webassembly_constructor stores each ctor's prototype there; the gc test guards' state reset (reset_copying_nursery_runtime_test_statetest_clear_closure_side_tables) clears that table from whatever parallel test thread runs it. With ~1600 tests in flight, a wipe lands between the test's install and its webassembly_constructor_proto read-back on virtually every run — which is why it read as deterministic in parallel and green at --test-threads=1 (CI's canonical mode never runs anything concurrently with it).

The repo already has a contract for exactly this: such tests must hold gc::global_side_table_test_lock (the guards hold it for their lifetime). The failing tests simply didn't.

The other families (acceptance item: do they share the root cause?)

family verdict
closure::dynamic_props (tests_1802) Same class, two extra mechanisms. The scanner test asserted a one-shot try_lock succeeds — any parallel thread briefly holding CLOSURE_PROPS (e.g. the WebAssembly test installing prototypes!) failed it spuriously. That panic then poisoned the module's test mutex, cascading a PoisonError into its sibling — one real race read as three failures.
prop_plan Same class, different globals. store_plan_check verdicts are invalidated by PROP_PLAN_EPOCH (bumped by every GC cycle's dead-owner fan-out, any thread) and VTABLE_GEN (bumped by every class method registration, any thread). A bump between record and check legitimately flushes the entry, so single-shot asserts are order-dependent. No lock can help (the bump sources are unguarded by design) — the tests now retry; a genuine regression still fails every lap.
gc::tests::teardown Related but distinct. These measure exact deltas of the process-global Map/Set side-deallocation counters across spawn/join windows; the three siblings' probe threads land inside each other's windows (near-deterministic under a test filter that leaves them running alone together). Fixed with a module lock + lower bounds on the cross-thread outer-window asserts — the exactly-once / growth-ownership core properties stay asserted exactly, inside the probe threads.
native_module_stream, gc::tests::runtime_roots Never reproduced across 19 consecutive default-parallel full-suite runs + 5 harsher filtered runs on this box (Windows 11, 16 threads). Left untouched; if they resurface the mechanisms above are the places to look first.

Two additional faces surfaced while verifying and are fixed here too:

  • object/tests.rs: builtin_prototype_methods_reject_dynamic_new (reads ctor prototype through CLOSURE_PROPS via populate_global_this_builtins), the two date_to_json_* tests (populate-then-read the global SYMBOL_PROPERTIES table), text_encoding_stream/navigator shape tests — and notably three tests in that file were themselves unguarded wipers, calling test_clear_closure_side_tables() without the lock.
  • url::node_compat: the path-to-file-URL tests read current_dir() twice and compare, racing typed_feedback's CurrentDirGuard (std::env::set_current_dir is process-wide). Added a crate-wide cfg(test) cwd lock (test_support::process_cwd_test_lock) held by the writer guard and the readers.

Hardening that keeps failures honest

  • Test-lock acquisitions are now poison-tolerant (unwrap_or_else(PoisonError::into_inner)) — one assert failure reads as one failure, not a cascade. The guarded data is () in every case.
  • The tests_1802 try_lock probe retries with a yield: the regression under test is a same-thread scanner hold, which can never succeed no matter how long we wait, while a foreign holder releases in microseconds. Poisoned counts as free (poison means a panicking holder released it).

Not touched (deliberately)

  • Production CLOSURE_PROPS accessors use if let Ok(...) = lock(), which silently treats a poisoned mutex as empty. Poison only arises from a panic while holding, which the fixes above remove from the test suite; migrating those ~dozen production sites to poison-tolerant locking is a separate cleanup.
  • exactly_once's inner idempotence asserts still compare global-counter snapshots exactly; a non-sibling thread exit inside that microsecond window remains theoretically possible. Making that airtight needs per-thread deallocation accounting — out of scope.

Verification

  • Before: default-parallel cargo test -p perry-runtime --lib failed 2–4 tests every run (reproduced on Windows: the WebAssembly test + tests_1802 ×2 + prop_plan, exactly the issue's families).
  • After: 19 consecutive default-parallel full-suite runs green (1646/1646), 5 runs of the harsher filtered reproducer (-- tests_1802 prop_plan global_this_webassembly teardown node_compat) green, and --test-threads=1 (CI's mode) green.
  • cargo fmt --check clean; clippy introduces no new lints in touched files; check_file_size.sh and addr_class_inventory.py pass.

No version bump (maintainer bumps at merge). Changelog fragment follows in a second commit once this PR has its number.

Summary by CodeRabbit

  • Bug Fixes

    • Improved test-suite reliability when tests run in parallel.
    • Prevented flaky failures caused by shared runtime state, temporary working-directory changes, and concurrent cleanup.
    • Stabilized garbage-collection, object metadata, closure, URL, and WebAssembly-related tests.
    • Added resilience to test lock poisoning and concurrent state invalidation.
  • Documentation

    • Added a changelog entry documenting parallel test-order independence and validation results.

@coderabbitai

coderabbitai Bot commented Aug 5, 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: 347bbf6b-720b-4330-bc73-d21bc66627f5

📥 Commits

Reviewing files that changed from the base of the PR and between 5e236e6 and 4461e37.

📒 Files selected for processing (10)
  • changelog.d/7445-parallel-test-order-independence.md
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/gc/tests/teardown.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/object/global_this_webassembly.rs
  • crates/perry-runtime/src/object/prop_plan.rs
  • crates/perry-runtime/src/object/tests.rs
  • crates/perry-runtime/src/test_support.rs
  • crates/perry-runtime/src/typed_feedback/tests.rs
  • crates/perry-runtime/src/url/node_compat.rs

📝 Walkthrough

Walkthrough

This PR makes perry-runtime --lib tests order-independent under default parallel cargo test. It adds test-only locks for process-global state, adds retry logic for epoch-sensitive assertions, relaxes cross-thread counter assertions, and documents the test-suite change.

Changes

Runtime test isolation

Layer / File(s) Summary
Serialize process CWD mutations
crates/perry-runtime/src/lib.rs, crates/perry-runtime/src/test_support.rs, crates/perry-runtime/src/typed_feedback/tests.rs, crates/perry-runtime/src/url/node_compat.rs
Adds test-only process_cwd_test_lock() behind #[cfg(test)]. CurrentDirGuard now holds that lock for its lifetime, and two URL tests acquire the same lock before current_dir() reads.
Serialize side-table based tests
crates/perry-runtime/src/closure/dynamic_props.rs, crates/perry-runtime/src/object/tests.rs, crates/perry-runtime/src/object/global_this_webassembly.rs
Adds poison-tolerant side-table lock usage in dynamic props tests, applies the global side-table lock to object and WebAssembly namespace tests, and makes one lock-freedom assertion retry on WouldBlock.
Retry epoch-sensitive plan assertions
crates/perry-runtime/src/object/prop_plan.rs
Store-plan and read-plan tests now retry record and lookup assertions when concurrent epoch or vtable updates invalidate cached entries.
Stabilize GC teardown counter checks
crates/perry-runtime/src/gc/tests/teardown.rs, changelog.d/7445-parallel-test-order-independence.md
Adds a poison-tolerant teardown counter lock, introduces an exact-once test that validates deltas inside the probe thread, changes post-join counter checks to lower bounds, and records the parallel-test fixes in the changelog.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6963 — Both PRs change perry-runtime test-stability behavior and use poison-tolerant test locking patterns.

Suggested labels: bug, tooling

Suggested reviewers: thehypnoo, andrewtdiz

✨ 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 fix/6965-webassembly-test-order

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 pushed a commit that referenced this pull request Aug 5, 2026
Ralph Kuepper added 2 commits August 5, 2026 15:05
…er default-parallel cargo test (#6965)

Root causes, all process-global test-visible state raced by parallel test
threads (CI's --test-threads=1 never sees any of them):

1. The gc test guards' state reset (test_clear_closure_side_tables and
   friends) wipes CLOSURE_PROPS / SYMBOL_PROPERTIES from whatever thread
   runs it. Tests that populate-then-assert those globals without holding
   global_side_table_test_lock lose their entries mid-test — the
   deterministic 'Module.prototype must exist' failure, plus the same
   shape in object/tests.rs (three of which were themselves unguarded
   wipers).
2. tests_1802's scanner test asserted a ONE-SHOT try_lock succeeds; any
   parallel thread briefly holding CLOSURE_PROPS failed it spuriously,
   and the panic poisoned the module's test mutex, cascading a
   PoisonError into its sibling.
3. The prop_plan tests race two global invalidation counters
   (PROP_PLAN_EPOCH: every GC cycle; VTABLE_GEN: every class
   registration) between record and check.
4. The gc teardown tests measure exact deltas of the process-global
   Map/Set side-deallocation counters across spawn/join windows — the
   siblings' probe threads land inside each other's windows.
5. typed_feedback's CurrentDirGuard mutates the process cwd while the
   url path-to-file-URL tests read current_dir() twice and compare.

Fixes: take global_side_table_test_lock in every affected
populate-then-assert test; retry the try_lock probe (a same-thread
scanner hold can never succeed, a foreign hold releases); make test-lock
acquisition poison-tolerant; retry prop_plan record→check laps (a real
regression fails every lap; a concurrent bump costs one); serialize the
teardown module and lower-bound its cross-thread outer-window asserts
(the exact-delta core properties stay asserted inside the probe
threads); add a crate-wide cfg(test) process-cwd lock held by the writer
guard and the url readers.

Verified: 19 consecutive default-parallel full-suite runs green
(1646/1646) plus 5 runs of the harsher filtered reproducer;
--test-threads=1 still green. native_module_stream and
gc::tests::runtime_roots (the issue's other named families) never
reproduced in any of those runs.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audited and merged, rebased onto today's main (it was 3 commits behind, which also explains the test-count difference you'd see against a fresh checkout).

Reproduced the "before" independently on macOS arm64 (16 threads): 3 default-parallel runs on main failed 1, 3, and 1 tests respectively — nondeterministic, exactly as described. After the rebase: 6/6 default-parallel runs green at 1699 passed / 0 failed, plus --test-threads=1 green. Totals match main (1702 either way), so nothing was dropped to get there.

One correction worth having, in your favour. You listed native_module_stream as never reproduced across 19 runs on Windows and left it untouched. On this box it failed in all three baseline runs — stream_constructors_expose_static_method_values, the most reliable failure I saw. It is green in all six post-fix runs. So the family you couldn't reproduce shares the CLOSURE_PROPS root cause after all, and your fix covers it; the Windows box just never exposed it. Worth knowing the mechanism is broader than the evidence you had.

The assertion weakening is sound, and I checked rather than assumed. Converting assert_eq! to >= is the change most likely to quietly destroy a test, so I traced where the exactness went: the exactly-once and growth-ownership assert_eq!s survive verbatim at lines 81–104 and 140–149, inside the probe threads where the window is genuinely owned. Only the cross-thread outer windows — where a sibling's deallocations legitimately land in the delta — became bounds. Assert exactly where you own the window, bound where you don't, is the right split.

The retry loops can still fail — proven by sabotage, not by reading. A bounded retry under an assert is the other classic way to get a gate that cannot go red, so I made store_plan_record an early-return no-op and rebuilt: record_then_check_hits_and_epoch_bump_invalidates and vtable_generation_bump_invalidates both FAILED, while the two prop_plan tests that don't depend on the recorder stayed green. (0..64).any(...) under assert! returns false on exhaustion, so a genuine regression fails every lap exactly as you claim. Tree restored clean afterward.

Your reasoning that only the positive direction needs the retry — thread-local caches mean no parallel thread can turn a MISS into a spurious hit — holds, and it's why the negative assertions correctly stayed single-shot.

Note that lint is red on main for an unrelated reason (a stale public-benchmark baseline, failing ≥7 days and needing a quiet host to regenerate), so this went in by admin merge rather than waiting on a check that cannot currently pass.

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: namespace_members_exist_with_expected_shapes fails deterministically under default-parallel cargo test (green serially)

1 participant