Skip to content

gc: zod dep-corpus under PERRY_GC_SCHEDULE_RATE=1 with the quarantine OFF dies with a rooting-shaped TypeError (seed 1, safepoint 3319) #7803

Description

@proggeramlug

Found by #7793's witness during its merge audit, in the sabotage arm that turns the from-space quarantine OFF — i.e., this is a latent defect on main (v0.5.1458 runtime), not a defect of that PR, and the shipped witness configuration (quarantine ON) runs the same workload clean.

Reproduce

cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
npm ci --ignore-scripts --no-audit --no-fund
target/release/perry test-files/gc-dep-corpus/main.ts -o /tmp/w
PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 \
PERRY_GC_PROTECT_FROMSPACE=0 PERRY_GC_DIAG=1 /tmp/w

Observed (M1 Pro, macOS, release build):

TypeError: Cannot read properties of undefined (reading 'toString')
    at <anonymous>
[gc-schedule] done: seed=1 safepoints=3319 scheduled_collections=3319 polls_paced=23075 copying_minors=3319 moved_objects=385030 loop_polls=26394

exit 1, at safepoint 3,319 of the ~8,000 the full run performs. The workload is single-threaded, so the seeded schedule should replay exactly ((seed, counter) — one observation so far; the box was at load 20+ and a second confirmation run was cut off, so start by confirming determinism).

Why this smells like a #7154-class rooting bug and not an instrument bug

  • The same binary, same seed, same rate with PERRY_GC_PROTECT_FROMSPACE=1 (depth 800) passes: 8,022 copying minors, 852,411 objects moved, output byte-identical to the unscheduled run — verified twice.
  • The failure shape is the classic surfacing form: TypeError on an undefined that should be an object, cycles after the collection that lost it.
  • The quarantine changes recycling — retired from-space page-sets are held (and PROT_NONE'd) instead of being recycled into Eden. Two readings, both worth chasing:
    1. the allocation/recycling dynamics under quarantine shift which safepoints land in the vulnerable window, so the bug's window simply never lines up in the protected arm (the protected arm also paced differently: 63,936 polls vs 26,394 at the failure point); or
    2. less likely, a stale read that the quarantine should be catching is landing in memory the quarantine does not cover (same-cycle, pre-retirement).
  • If (1), PERRY_GC_SCHEDULE_SEED sweeps over the PROTECTED arm (scripts/gc_schedule_fuzz.sh) should eventually find a seed whose protected run faults precisely — that would hand us the exact stale address and retiring minor.

Context

The zod dependency corpus is exactly the population #7280 showed the curated fixtures don't reach (js_object_assign_one, js_new_function_construct). #7793's shipped witness gates the protected arm in CI; this issue tracks the unprotected finding it flushed out on its first run.

Activity

  1. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Status after working #7963 / #7964 (PR #7978):

    Still not reproducible — test-files/gc-dep-corpus/main.ts does not link. I confirmed the failure again on a build of origin/main (a769faf, PERRY_NO_AUTO_OPTIMIZE=1, PERRY_RUNTIME_DIR pinned): 45 distinct undefined _perry_fn_node_modules_zod_… symbols. The verdict on #7964 is genuine compiler gap (named re-export from an export * barrel emits a reference the barrel never defines), not a stale pin — details in that issue. So the blocker stands.

    PR #7978 does not unblock it. That fix is runtime-side (Object.defineProperty's receiver / key / DescView rooting); this failure is in module lowering / codegen.

    The candidate connection is now a little stronger, and still only a candidate. zod's descriptor traffic runs through both helpers in this family:

    #7803's symptom — Cannot read properties of undefined (reading 'toString') — is exactly what either a stale key string or a stale receiver in those loops produces: the property is defined under a garbage name or a dead owner, and the later read misses.

    Retest plan once #7964 lands: build the corpus, then run the two arms named in this issue (PERRY_GC_PROTECT_FROMSPACE=0 vs =1, seed 1, rate 1) against a build with #7978 in it and against one without. If #7978 closes it, the unprotected arm goes green; if not, the seed sweep over the protected arm (scripts/gc_schedule_fuzz.sh) is still the way to turn it into a precise fault.

  2. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Retested on bdfcba4a2 (v0.5.1499). Short version: the reproducer is stale, the subject is not. Please don't close.

    The corpus was unrunnable all week (#7964); #7980 (fec46413d) fixed it and
    test-files/gc-dep-corpus/main.ts now compiles and links (exit 0, 29.9 MB), on
    the corpus and zod@4.3.5 pin already in the tree — nothing regenerated or
    re-pinned.

    1. The filed reproducer no longer fails

    PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=0 PERRY_GC_DIAG=1
    → exit 0, 4 runs out of 4 (3 dedicated + seed 1 inside the sweep below), stdout byte-identical to the unscheduled run, with
    the instrument live rather than assumed (6,627–6,909 copying minors, ~872k
    objects moved, 63,936 loop polls per run).

    2. …but the class is still live: 3 of 16 seeds fail

    RATE=1 TIMEOUT=1800 PERRY_GC_PROTECT_FROMSPACE=0 PERRY_GC_DIAG=1 scripts/gc_schedule_fuzz.sh /tmp/w 16

    seed failure safepoint
    4 TypeError: value is not a function 738
    15 exit 134, [gc-pin-latch] FATAL 1870
    16 TypeError: Cannot read properties of undefined (reading 'issues') 626

    Seed 16 is the same message shape as the one you filed. Seed 15 is a different
    bug and is now #7990.

    3. The seeded schedule does NOT replay — this changes how the issue should be read

    You asked for determinism to be confirmed first. It does not hold: at a fixed
    seed, loop_polls is stable (63,936) but safepoints / scheduled_collections
    drift ~4% run to run (6627 → 6652 → 6909). Consequently every failure here is
    intermittent
    :

    seed in the sweep on re-run, same command
    4 FAIL pass, 2/2
    15 FAIL pass, 3/3
    1 (this issue's) pass pass, 4/4

    So §1 is a coin landing the other way, not a repair. The durable figure is the
    rate: ~19% of runs (3/16).

    4. The candidate cause is refuted

    gc-handoff/ROOTVEC-NOTES.md named #7962/#7978 (Object.defineProperties /
    Object.defineProperty rooting) as a candidate, explicitly unconfirmed. Sabotage
    A/B — both fixes reverted underneath current main, archives verified to have
    moved, relinked binary verified different:

    arm seed 1, rate 1, quarantine OFF
    main exit 0 ×3
    #7962 + #7978 reverted exit 0 ×2

    Reverting them does not bring it back. They are not what closed the seed-1
    window. (Tree restored and rebuilt afterwards, not git checkout-ed.)

    The workload is not the variable either: test-files/gc-dep-corpus/ is untouched
    since #7311 and the installed zod is 4.3.5 throughout, so the only delta between
    v0.5.1458 and v0.5.1499 is compiler + runtime.

    5. Your hypothesis (1) is confirmed on a second seed

    Seed 4 fails unprotected at safepoint 738; the same seed with the quarantine on
    at depth 800 passes
    , running 6,888 copying minors, with the instrument
    saturated (sets_held=800/800 bytes_protected=2095054848, 6,888 retired sets).
    Holding retired from-space pages out of Eden changes which addresses get
    recycled and the window stops lining up. The protected arm is therefore not
    evidence of health here, and scripts/gc_dep_scale_witness.sh (quarantine ON)
    structurally cannot catch this window on this workload.

    I ran your suggested way out — a seed sweep over the protected arm — and it
    has not produced a protected-arm fault so far, so that route did not pay off in
    this session.

    Suggested disposition

    Keep open; retitle away from "seed 1, safepoint 3319" and toward the rate, or
    close in favour of a fresh issue quoting §2. Full audit trail, including the
    things that did not work, is in gc-handoff/ZOD-NOTES.md (#7989).

    Unrelated footgun found on the way: PERRY_GC_DIAG=0 enables diagnostics
    (telemetry.rs:11 uses var_os(...).is_some()), which silently collapsed one of
    my A/B arms. PERRY_GC_PROTECT_FROMSPACE does not have this bug, so the =0 in
    this issue's repro really is off.

  3. proggeramlug commented on Aug 13, 2026

    @proggeramlug
    ContributorAuthor

    Closing with causal evidence, not because the old seed-1 reproducer merely stopped firing.

    The surviving population was localized to comparison operands in the zod new-Function/dyn_eval path. #8011 (main commit 0543e87) roots the left comparison operand across evaluation of the allocating right operand. Its causal A/B moved from 3 failures in 16 saturated quarantine-off runs to 26/26 passes after the fix, each under PERRY_GC_SCHEDULE_RATE=1 with roughly 6,400 copying minors and ~855k moved objects—so the stress subject was live, not inferred.

    #8021 (main commit 410dadd) then pinned the exact typed-Map comparison shape as strict_eq_rereads_a_map_operand_below_an_allocating_right_operand; current main a9a99d8 contains that emitted-IR regression and the registered corpus witnesses.

    This also accounts for the incoherent Map header reported separately in #7990. The old fixed-seed wording remains historically stale because schedule counts drift, but the defect class is now causally fixed and guarded.

  4. 8 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions