Skip to content

fix(runtime): tombstone squeeze published stale facts — un-aborts the runtime suite (#9108) - #9110

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix-9108-squeeze-parity
Aug 30, 2026
Merged

fix(runtime): tombstone squeeze published stale facts — un-aborts the runtime suite (#9108)#9110
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix-9108-squeeze-parity

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #9108 — my tombstone default-on fallout (kill switch PERRY_OBJECT_TOMBSTONES=0 cures the abort, which is how it was attributed). The squeeze shrank the keys array in place, then set_object_live_slot_count's unchanged-bound early return asserted parity against the still-stamped pre-squeeze descriptor → double panic → SIGABRT at object::reserved_floor::…at_scale, silently masking the ~1000 tests behind it on every branch (and the re-exec child's '1 passed' line can masquerade as green in tail-style greps — see the issue).

Two coherence fixes: the squeeze orders shape_drop → publish_object_shape_holes → set_object_live_slot_count; and publish_object_shape_holes takes logical_key_count from the ARRAY rather than the lineage (identical for the O(1) hole delete where length is untouched; the only correct source after a squeeze). Release binaries were never affected — debug_assert only — which is why all differential batteries stayed byte-identical while the suite died.

Verification: reserved_floor suite 6/0; full perry-runtime lib suite runs to completion, 2819 passed / 0 failed (was: abort at ~test 1804). Suite-integrity blocker for everyone's verification, so flagging for a fast merge.

Summary by CodeRabbit

  • Bug Fixes
    • Improved object property deletion and shape updates to keep internal object state consistent.
    • Fixed edge cases that could trigger assertion failures when removing properties.
    • Ensured logical property counts remain accurate after object shapes are republished.

…d publish (PerryTS#9108)

The tombstone squeeze (default-on since PerryTS#9038) shrank the keys array in
place and then called set_object_live_slot_count BEFORE republishing the
shape. When the floored bound is unchanged — the at-scale/overflow case —
that call's early return asserts parity against the STILL-STAMPED
pre-squeeze descriptor and SIGABRTs the debug suite (reserved_floor
at-scale test), masking the ~1000 tests behind it on every branch.

Two coherence fixes:
- squeeze_holes_and_delete orders shape_drop -> publish_object_shape_holes
  -> set_object_live_slot_count, so the bound publish always sees the
  squeezed descriptor;
- publish_object_shape_holes reads logical_key_count from the ARRAY, not
  the lineage: identical for the O(1) hole delete (length untouched), and
  the only correct source right after a squeeze changed the length.

Release binaries were unaffected (debug_assert only), which is why the
differential battery stayed byte-identical while the test suite died.
Full perry-runtime suite now runs to completion: 2819 passed / 0 failed.

Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c06c8767-f20f-407d-af5b-759cf4d76a88

📥 Commits

Reviewing files that changed from the base of the PR and between 0b6dea2 and 5b1c057.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/shapes_slot_list.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The runtime changes the bookkeeping order after hole squeezing. It publishes the squeezed shape before updating the live-slot bound. Shape publication now derives the logical key count from the current keys array.

Changes

Shape squeeze bookkeeping

Layer / File(s) Summary
Squeezed shape publication and live-slot update
crates/perry-runtime/src/object/delete_rest.rs, crates/perry-runtime/src/object/shapes_slot_list.rs
squeeze_holes_and_delete publishes the squeezed shape before calling set_object_live_slot_count. publish_object_shape_holes uses the current keys-array length for the logical key count.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 5b1c0

This localized runtime bookkeeping fix prevents stale shape metadata from causing debug-suite aborts while preserving existing deletion behavior; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the runtime tombstone squeeze fix and its effect on the aborted test suite.
Description check ✅ Passed The description explains the regression, identifies both coherence fixes, references issue #9108, and reports verification results. It does not reproduce the template headings or checklist, but it con…
Linked Issues check ✅ Passed The changes address issue #9108 by publishing the squeezed shape before updating the live-slot count and by deriving the logical key count from the current array. The reported runtime suite completion…
Out of Scope Changes check ✅ Passed The changes are limited to shape bookkeeping during tombstone squeezing and directly support the requirements in issue #9108. No unrelated code changes are identified.
Full details: Description check

Explanation

The description explains the regression, identifies both coherence fixes, references issue #9108, and reports verification results. It does not reproduce the template headings or checklist, but it contains the critical summary, change, issue, and test information.

Full details: Linked Issues check

Explanation

The changes address issue #9108 by publishing the squeezed shape before updating the live-slot count and by deriving the logical key count from the current array. The reported runtime suite completion also matches the issue objective of preventing the SIGABRT and masked tests.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Merged. Fixes #9108. This one deserves a note on how nearly I missed it, because the failure mode you documented in the issue caught me exactly as described.

The masking is worse than the abort. Under the dev profile on main (RUST_TEST_THREADS=1 cargo test --lib -p perry-runtime):

exit test … lines emitted abort markers
main 101 2069 1
this PR 0 2836 0

767 tests never ran on main. And the tail -1 of ^test result: on the aborting run reads:

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2822 filtered out

— the re-exec child, exactly the masquerade your issue warns about. My own validation harness greps ^test result: and takes the last line, so it would have reported this green. I now check the process exit code alongside it. Worth stating plainly since I have been reporting "runtime 2819 passed" on every PR today.

Second reason I initially could not reproduce it, which is worth recording for anyone else chasing this: --profile perry-dev inherits from release, so debug-assertions is off and shapes.rs:1539's debug_assert! is compiled out entirely. Main is clean under perry-dev and aborts under the default dev profile. CI covers both (test.yml:1057 runs the dev profile, :1609 runs perry-dev), so the gate was real; a local perry-dev-only workflow is blind to it.

The fix reads correctly on both halves. Ordering shape_drop → publish_object_shape_holes → set_object_live_slot_count is right because the early return in set_object_live_slot_count asserts parity against the stamped descriptor, which still carries the pre-squeeze logical_key_count until the publish runs — and your observation that the floored bound is usually unchanged at scale is what makes that early return the one that fires, which is why this reproduced at scale and not on small objects. Taking logical_key_count from the array rather than the lineage is the correct source of truth given the squeeze shrinks the array in place; the two agree for the O(1) hole delete, so the change is a no-op on that path.

One adjacent finding, filed separately as #9115: object_tombstone_deletes_enabled's doc comment says "Default OFF while the walker audit and differentials bake" while its body comment eight lines below says "Default ON" and the code defaults on. That is verbatim the gc_incremental_enabled shape CLAUDE.md cites (#6987, "a merge decision was made on the wrong one") — and it directly cost me time here, since I read the doc comment, concluded the mechanism was off by default, and initially mis-scoped the blast radius of this bug as latent rather than shipping.

Validation: runtime 2819 passed under both profiles with exit 0 (dev profile: 53.9 s, all 2836 test lines), codegen 1347, perry --bins 1066, fmt clean, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

@proggeramlug
proggeramlug merged commit 55c1368 into PerryTS:main Aug 30, 2026
24 of 46 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 30, 2026
…n identity — pi boot threw Cyclic __proto__

perry-compiled pi (13MB esbuild bundle) died at startup with
`TypeError: Cyclic __proto__ value` out of `js_object_set_prototype_of`,
with obj_bits == proto_bits exactly — the two ARGUMENTS were the same
pointer — while the chain behind the proto was healthy. None of the
bundle's 17 textual `Object.setPrototypeOf(` sites fired a JS logging
shim, because the self-set was manufactured upstream of the call: the
closure-literal singleton caches handed back ONE ClosureHeader for two
evaluations of the same function literal, so `setPrototypeOf(wrapped,
original)` (a graceful-fs-style wrap pattern) received one object twice
and correctly refused the "cycle".

Mechanism: `expr/closure.rs` routed closure literals through
`js_closure_alloc_singleton` (captureless arrows) and
`js_closure_alloc_with_captures_singleton` (arrows with captures, and
non-arrow literals whose captures are all boxes) keyed by
(func_ptr, capture bits). Two evaluations of the same literal with
bit-identical captures — e.g. an arrow capturing the same constant, or
any captureless arrow — came back `===`-equal. ECMA-262
OrdinaryFunctionCreate requires a fresh object per evaluation, and the
distinction is observable through `===`, expando properties, WeakMap
keys, addEventListener de-duplication, and `Object.setPrototypeOf`.
Minimal repros (byte-compared against node before/after):

    function mk() { return () => K; }        // captured arrow
    const a = mk(), b = mk();                // perry: a === b (node: false)
    Object.setPrototypeOf(a, b);             // perry threw Cyclic __proto__

and the same with `() => 1` (captureless). Both now match node.

Fix: gate every closure.rs literal singleton path on
`is_plain_async_step_body` — the file's existing detector for the
compiler-synthesized plain-async step closures (their terminal
`Stmt::ReleaseBoxes` arms cannot appear in user code). Those are the
closures the caches were built for (PerryTS#8269's parallel async-await
pattern re-creates them per resume with the same per-activation box
captures, and their identity never escapes the promise machinery), and
they keep the fast path. Every user-authored arrow and function
expression now mints a fresh closure. Runtime-internal singleton users
(function-declaration references, property_get/i18n/arrays wrapper
thunks) are separate paths and unchanged. A genuine
`setPrototypeOf(x, x)` still throws — the cycle check is untouched.

Perf note: this deliberately gives back the user-arrow closure reuse
from the PerryTS#8269/PerryTS#8291 captured-singleton extension (e.g. ECS
`World.executeEntityCommands`' per-call inner arrow) and the captureless
user-arrow singleton at literal sites; a sound replacement needs
escape-aware caching rather than identity-violating sharing.

Validation: repros above and test-files/
test_gap_9090_closure_literal_identity.ts byte-identical to node;
`cargo test -p perry-runtime --lib -- --test-threads=1` green — 2813
passed, 0 failed with `--skip reserved_floor` (that module's at-scale
tests SIGABRT on this pre-PerryTS#9110 base; known PerryTS#9108/PerryTS#9110, unrelated);
`cargo test -p perry-codegen`: 283+75 passed after updating the four
native_proof_regressions pins from `js_closure_alloc_singleton` to
`js_closure_alloc` (their real subject — the alloc storing the public
wrapper pointer — is preserved); one pre-existing env-leak flake
(`packed_f64_loop_unary_math_store_versions_with_side_exit`) passes in
isolation.

Claude-Session: https://claude.ai/code/session_01Ay8VyLkKbm8Hkc1xmvTEsP
proggeramlug added a commit that referenced this pull request Aug 30, 2026
…ity — pi boots (#9128)

* fix(runtime): closure-literal singleton caches conflated user function identity — pi boot threw Cyclic __proto__

perry-compiled pi (13MB esbuild bundle) died at startup with
`TypeError: Cyclic __proto__ value` out of `js_object_set_prototype_of`,
with obj_bits == proto_bits exactly — the two ARGUMENTS were the same
pointer — while the chain behind the proto was healthy. None of the
bundle's 17 textual `Object.setPrototypeOf(` sites fired a JS logging
shim, because the self-set was manufactured upstream of the call: the
closure-literal singleton caches handed back ONE ClosureHeader for two
evaluations of the same function literal, so `setPrototypeOf(wrapped,
original)` (a graceful-fs-style wrap pattern) received one object twice
and correctly refused the "cycle".

Mechanism: `expr/closure.rs` routed closure literals through
`js_closure_alloc_singleton` (captureless arrows) and
`js_closure_alloc_with_captures_singleton` (arrows with captures, and
non-arrow literals whose captures are all boxes) keyed by
(func_ptr, capture bits). Two evaluations of the same literal with
bit-identical captures — e.g. an arrow capturing the same constant, or
any captureless arrow — came back `===`-equal. ECMA-262
OrdinaryFunctionCreate requires a fresh object per evaluation, and the
distinction is observable through `===`, expando properties, WeakMap
keys, addEventListener de-duplication, and `Object.setPrototypeOf`.
Minimal repros (byte-compared against node before/after):

    function mk() { return () => K; }        // captured arrow
    const a = mk(), b = mk();                // perry: a === b (node: false)
    Object.setPrototypeOf(a, b);             // perry threw Cyclic __proto__

and the same with `() => 1` (captureless). Both now match node.

Fix: gate every closure.rs literal singleton path on
`is_plain_async_step_body` — the file's existing detector for the
compiler-synthesized plain-async step closures (their terminal
`Stmt::ReleaseBoxes` arms cannot appear in user code). Those are the
closures the caches were built for (#8269's parallel async-await
pattern re-creates them per resume with the same per-activation box
captures, and their identity never escapes the promise machinery), and
they keep the fast path. Every user-authored arrow and function
expression now mints a fresh closure. Runtime-internal singleton users
(function-declaration references, property_get/i18n/arrays wrapper
thunks) are separate paths and unchanged. A genuine
`setPrototypeOf(x, x)` still throws — the cycle check is untouched.

Perf note: this deliberately gives back the user-arrow closure reuse
from the #8269/#8291 captured-singleton extension (e.g. ECS
`World.executeEntityCommands`' per-call inner arrow) and the captureless
user-arrow singleton at literal sites; a sound replacement needs
escape-aware caching rather than identity-violating sharing.

Validation: repros above and test-files/
test_gap_9090_closure_literal_identity.ts byte-identical to node;
`cargo test -p perry-runtime --lib -- --test-threads=1` green — 2813
passed, 0 failed with `--skip reserved_floor` (that module's at-scale
tests SIGABRT on this pre-#9110 base; known #9108/#9110, unrelated);
`cargo test -p perry-codegen`: 283+75 passed after updating the four
native_proof_regressions pins from `js_closure_alloc_singleton` to
`js_closure_alloc` (their real subject — the alloc storing the public
wrapper pointer — is preserved); one pre-existing env-leak flake
(`packed_f64_loop_unary_math_store_versions_with_side_exit`) passes in
isolation.

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

* fix(runtime): name-keyed builtin-member reads must see user overrides — pi boot threw Cyclic __proto__ (part 2)

With the closure-literal identity fix in place, pi still died at startup
with `TypeError: Cyclic __proto__ value`, obj_bits == proto_bits exactly.
The instrumented throw site showed both arguments were ONE closure with
`func_ptr = 0xBADD_DEAD` (BOUND_METHOD_FUNC_PTR, capture_count 3) and a
healthy 3-link chain behind it — the canonical bound-native callable that
`bound_native_callable_export_value` mints once per (module, member).

The failing code is graceful-fs's module init, bundled into pi
(pi-bundle.mjs:6621/6686/6705):

    var chdir = process.chdir;
    process.chdir = function (d) { ... };
    if (Object.setPrototypeOf) Object.setPrototypeOf(process.chdir, chdir);

and the same wrap for fs.rename / fs.read. Under perry the patch write
did not round-trip on the re-read, so setPrototypeOf received the SAME
canonical closure for both arguments — a self-set — and the cycle check
correctly refused it. The earlier probe of this exact shape passed
because it patched a PLAIN object, where writes round-trip; the failure
needs a builtin namespace receiver. The JS shim over
`Object.setPrototypeOf(` never fired because the conflation happens in
the native member-READ, upstream of the call.

Root cause: user writes to builtin namespace members are stored in two
different places depending on the lowering — computed stores
(`process[k] = fn`) go through `nm_field_set_override` into
`NATIVE_NAMESPACE_PROP_OVERRIDES`, while static stores
(`process.chdir = fn`) reach the generic store path and land as an own
dynamic field on the canonical namespace object. The NAME-KEYED read
entries carry no object pointer and consulted only the override table:

  * `js_native_module_property_by_name` (codegen static reads of
    process.* members) missed own-field stores, so the graceful-fs
    static patch was invisible to the static re-read;
  * `js_native_module_esm_export_value` (codegen property reads off a
    builtin DEFAULT import — `import fs from "node:fs"; fs.rename`)
    consulted NOTHING (consult_overrides=false plus its own snapshot
    cache), so no fs patch was ever visible. In Node the default import
    of a core module is the live mutable CJS exports object, so the
    patched value must win; the tls DEFAULT_* cache-coherence hack was
    the ad-hoc version of this for three keys.

Fix: `native_namespace_user_value(module, prop)` consults the override
table and then the canonical namespace object's own field (never
creating a namespace — if none exists, no user store can have landed on
one). Both name-keyed read entries call it before any built-in
resolution or snapshot cache. Named ESM import bindings of core modules
snapshot at module init before user patches run, so their intended
snapshot semantics are unaffected in the eager case.

Validation: r11-r16 probe matrix (process/fs, static/computed reads and
writes) and test-files/test_gap_9091_native_member_patch_roundtrip.ts
byte-identical to node; a genuine `setPrototypeOf(x, x)` still throws.

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

* style: rustfmt the closure-identity fix

---------

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.

regression(main): shapes.rs ShapeId/ObjectHeader disagreement aborts the entire perry-runtime test suite (SIGABRT, ~1000 tests masked)

1 participant