Repository navigation
bug(codegen): custom property on a function object reads back undefined (fn.errors) #1802
Description
Summary
A pointer-valued property assigned to a function object (e.g. ajv standalone's validate.errors = [...]) loses its nested contents after a GC: the immediate value (the array) survives, but its elements (objects) are collected and read back as a stale string/undefined. Surfaced by #1680 (ajv) and blocks the fast-json-stringify path under #1681.
Repro (deterministic with gc())
function fb() { (fb as any).errors = [{ msg: "x" }]; }
fb();
gc();
const e = (fb as any).errors;
console.log(typeof e[0], e[0]?.msg); // node: "object" "x" — perry: "string" undefinedAlso reproduces without gc() under allocation pressure (a minor GC between the set and the read). Build & run:
cargo build --release -p perry-runtime -p perry-stdlib -p perry
PERRY_NO_AUTO_OPTIMIZE=1 ./target/release/perry compile repro.ts -o /tmp/r && /tmp/r
Controls that work (same nested-object-array shape): stored in a Map (m.set("k",[{msg}])), or as an Object overflow field (object with >~32 fields). Both survive GC. So the bug is specific to the closure dynamic-property side-table.
Root cause
- Closure dynamic props live in an external side-table
CLOSURE_PROPS: HashMap<usize /*closure addr*/, HashMap<String, f64>>incrates/perry-runtime/src/closure/dynamic_props.rs. All stored values are JSValues (closure_set_dynamic_proponly stores JSValue bits). - The only registered scanner,
scan_closure_dynamic_props_roots_mut(registered mutable atgc/mod.rs:~709), early-returns unlessis_metadata_rewrite_phase()— so during the Mark / CopyingMark phases it does nothing and the prop values are never marked, so a value reachable only via the prop (and its transitive contents) is collected. - The rewrite descriptor does visit these slots (
gc/layout.rs:1035), but rewrite only follows already-forwarded pointers — it can't keep an otherwise-unreachable value alive. - The working analog, Object overflow fields, keeps values alive via
scan_overflow_fields_roots_mut(crates/perry-runtime/src/object/mod.rs:969) which has no early-return and runs in all phases.
Why the naive fix is WRONG (don't just remove the early-return)
Removing the early-return makes the mutable/evacuation scanner mark during the copying minor-GC (CopyingMark) → SIGSEGV (exit 138) under default gen-GC and PERRY_GEN_GC_EVACUATE=0. Under PERRY_GEN_GC=0 (full mark-sweep) it doesn't crash but still doesn't trace. The early-return is load-bearing for the copying path. The fix must treat the copying minor-GC path differently from full mark-sweep.
Fix approach (mirror overflow fields, across ALL GC paths)
- Full mark-sweep keep-alive: ensure prop values are marked during the mark phase (as overflow's no-early-return scanner does), but without letting the evacuation scanner crash the copying path.
- Copying minor-GC: trace/evacuate prop values when the owner closure is copied, not via the evacuation root scanner. The copying collector scans copied objects via
visit_gc_rewrite_slots(gc/copying.rs:320), which includes closure-prop slots (gc/layout.rs:1035) — verify the side-table is keyed by the address used at scan time (the move-handlerclosure_dynamic_props_owner_moved,gc/types.rs:419, re-keys on move; check ordering vs the copy-scan, which may look up by the new address before the re-key). - Get the crash backtrace first:
PERRY_DEBUG_SYMBOLS=1build, run the repro binary underlldb,btat the SIGSEGV — don't guess the copying-path failure.
Verification (all must hold)
- The repro returns
"object" "x"under: default gen-GC,PERRY_GEN_GC=0,PERRY_GEN_GC_EVACUATE=0, andPERRY_GC_FORCE_EVACUATE=1. PERRY_GC_VERIFY_EVACUATION=1passes (no stale-forwarded panics); no SIGSEGV in any mode.cargo test --release -p perry-runtimegreen.- Add a
test-files/parity test of the repro shape (node-comparable — functions can carry own properties).
Key files
crates/perry-runtime/src/closure/dynamic_props.rs—scan_closure_dynamic_props_roots_mut,closure_set/get_dynamic_prop,closure_dynamic_props_owner_moved,visit_closure_dynamic_prop_value_slots_mut.crates/perry-runtime/src/object/mod.rs:964-1002—scan_overflow_fields_roots/_mut(working template).crates/perry-runtime/src/gc/layout.rs:1015-1038— rewrite descriptors (Object overflow + Closure dynamic props).crates/perry-runtime/src/gc/copying.rs:320—scan_object_fields(copying child scan).crates/perry-runtime/src/gc/roots.rs—RuntimeRootVisitormodes (Mark/CopyingMark/Rewrite/Copy),visit_nanbox_f64_slot,is_metadata_rewrite_phase.crates/perry-runtime/src/gc/mod.rs:~709— scanner registration.
Downstream: this (+ #1803) gates full fast-json-stringify byte-parity. The #1681 self-hosted-precompile() mechanism is already merged; it needs byte-correct native compilation of the generated serializer (which delegates leaf formatting to fjs's Serializer, compiled via compilePackages).
Activity
proggeramlug commentedon May 25, 2026
Root cause (pinpointed)
The closure dynamic-property mechanism exists and works —
closure_set_dynamic_prop/closure_get_dynamic_propincrates/perry-runtime/src/closure/dynamic_props.rs, backed by aHashMap<usize /*closure addr*/, HashMap<String, f64>>side-table, wired intojs_object_get/set_field_by_name. The bug is GC-correctness, not missing support.Minimal matrix (
fnwith a custom prop):[{msg}]set inside bodyundefined[{msg}]set top-levelundefined[{msg}]Two GC problems with the side-table:
[{...}]) triggers a GC that moves/evacuates the closure, its address changes, so the lookup by the new address misses →undefined. (Number-valued A survives because it reads back before a move; D reads before any GC.)So
validate.errors(an array) set by an ajv standalone validator is lost after the next allocation/GC — which is exactly the ajv.errorssymptom in #1680.Fix direction
Make the side-table move-safe and its values rooted. Options: key by a stable closure id instead of raw address (and update on move), or register the side-table as a GC scanner that (a) rewrites closure keys on evacuation and (b) marks stored pointer values. This is a GC-sensitive change — medium/large, needs care around the generational evacuation path.
feat(compiler): self-hosted build-time precompile() — Perry runs its …
proggeramlug commentedon May 26, 2026
Refined finding (re-verified on current
main)Still reproduces:
(f).errors = [{msg}]then reading(f).errorsreturnsundefined(number-valued props work). Important correction to my earlier note: this is not simply "no GC support" — the machinery is fully wired:gc/mod.rs:709registersscan_closure_dynamic_props_roots_mutas a mutable root scanner (marks + rewrites the stored values),gc/types.rs:419callsclosure_dynamic_props_owner_moved(old, new)to re-key on closure evacuation,gc/layout.rs:1035visits value slots for rewrite, and there's a write barrier on store.Yet a pointer-valued prop is lost while a number-valued prop survives, even in a tiny program. Since the read returns a clean
undefined(entry gone, not a dangling pointer), the side-table key is being lost — consistent with the owner closure being moved/tenured by a minor GC triggered by the array allocation, and the re-key (closure_dynamic_props_owner_moved) producing an owner address that doesn't match the address used at the read site (or not firing for this closure shape). Number props survive because no pointer is involved (and/or less GC pressure).Next step is GC tracing (
PERRY_GC_DIAG=1, and lldb on the re-key path) to confirm whether the move hook fires for these closures and whether old/new owner addresses line up with the get/set sites. This is a focused GC-evacuation debugging task in a historically fragile subsystem — not a quick fix.proggeramlug commentedon May 26, 2026
Full root-cause (investigated end-to-end)
The corruption is not a closure-property storage bug and not about closure movement — it's that closure dynamic-property values are never marked/traced during GC, so a pointer-valued prop's nested contents get collected. Disambiguated repro:
The array survives (length 1) but its inner object is swept;
e[0]reads a stale slot.Why — the precise gap (vs the working Object-overflow analog)
Object overflow fields (the identical external-side-table pattern) survive GC because they register two scanners:
scan_overflow_fields_roots(mark)— non-mutable mark/keep-alive (RuntimeRootVisitor::for_copy(mark)), andscan_overflow_fields_roots_mut— mutable rewrite,plus the owner object copies its overflow slots during minor GC via the rewrite descriptor (
gc/layout.rs:1029).Closure dynamic props register only
scan_closure_dynamic_props_roots_mut(mutable), and it early-returns unlessis_metadata_rewrite_phase()— so values are never marked in the Mark or Copy phases. The rewrite descriptor visits closure-prop slots (gc/layout.rs:1035) but rewrite only follows already-forwarded pointers; it can't keep an otherwise-unreachable value alive.Why the naive fix (remove the early-return) is wrong
It makes the mutable/evacuation scanner mark in the copying minor-GC (
CopyingMark) phase → SIGSEGV (exit 138) under default gen-GC andPERRY_GEN_GC_EVACUATE=0; underPERRY_GEN_GC=0(mark-sweep) it doesn't crash but still doesn't trace (e[0]stays string). Overflow avoids the crash because its minor-GC keep-alive flows through the owner-object copy, not the mutable root scanner.Correct fix (careful, multi-path)
Mirror overflow exactly: add a non-mutable keep-alive scanner for closure props (
scan_closure_dynamic_props_roots(mark)viafor_copy, registered withgc_register_root_scanner) for the full-mark-sweep path, and ensure the copying minor-GC traces the prop values when the owner closure is copied (a mark/copy analog of thelayout.rs:1035rewrite hook), not the evacuation scanner. Verify withPERRY_GC_VERIFY_EVACUATION=1across default /GEN_GC=0/GEN_GC_EVACUATE=0. This is focused, instrumented GC work — the early-return is load-bearing and must not simply be removed.proggeramlug commentedon May 26, 2026
Self-contained handoff prompt (paste to a fresh agent)
fix(runtime): trace closure dynamic-prop values in all GC phases (#1802…
proggeramlug commentedon May 28, 2026
Fixed in #2284 — scan_closure_dynamic_props_roots_mut now traces values in every GC phase, matching scan_overflow_fields_roots_mut.