Skip to content

GC: #7154's residual is NOT fixed — the loop-polls config is red 0/30, and stock zod alone fails 5/40 #7280

Closed

Description

@proggeramlug

#7154 was closed on a sfw-registry --help 30/30 result. That result could not be reproduced here, and the configuration a revert would ship is red.

The gate result

perry 4e9b1b7ea (origin/main, includes #7275):

arm result
default (no compile env, no run env) clean 30/30
PERRY_GC_MOVING_LOOP_POLLS=1 compiled AND run — the config a revert ships red 0/30, TypeError: object is not a function
POLLS=1 compiled, PERRY_GEN_GC=0 at run clean 10/10
POLLS=1 compiled, polls not set at run clean 10/10
default binary, POLLS=1 at run only — the false-green trap clean 10/10
evac_minor (default binary, HEAP_LIMIT=8 INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=off) red 0/10, live (copied_objects=6084)

Liveness asserted both ways: the revert arm ran 6 copying minors (copied_objects=50695…); the default arm ran 0. The result tracks collector mode exactly.

Note the fifth row. Setting POLLS=1 at run time against a default-compiled binary is clean 10/10 — because the poll codegen isn't in the binary. Anyone verifying that way gets a false green.

sfw-registry is not in this repo

Two independent sweeps found no source tree and no binary — it is a private checkout. So the 30/30 is neither confirmed nor refuted on that workload. It is refuted on a faithful substitute (86 modules, 8 endpoints × 12 cross-module defineApiCall(...) at module init, shaped after the frames in #7154's own disassembly) and on stock zod alone.

The library-only control is the important one — 20 lines, no scaffolding: import zod; build schema; safeParse ×96. 35/40 clean under the revert config; 40/40 on the shipped default. Failures print TypeError: value is not a function — #7154's exact string.

Instruction-level identification

Under #7196's quarantine (ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800):

FAULT: signal 10 ... RETIRED FROM-SPACE  retired_by_minor=#18
last-known object: obj_type=2 size=72
2  perry_fn_node_modules_zod_src_v4_core_util_ts__clone + 1964
3  ...native_call_method::dispatch_handle
5  js_typed_feedback_native_call_method_by_id

#7154's final comment reports util_ts__clone + 1996, obj_type=2 size=72 — same function, same object type and size. The disassembly matches its prose exactly:

and  x21, x8, #0xffffffffffff              ; receiver -> bare register
bl   _js_typed_feedback_observe_property_get   ; observe() ALLOCATES
lsr  x9, x21, #20                          ; <-- reported pc
ldr  x10, [x9, #0x10]                      ; STALE deref

A comment that is too strong

policy.rs:1408 states the loop-polls route is "sound by construction" because it defers to a precise-root safepoint. That does not hold here. The stale value is a codegen-held bare register live across a runtime call that allocates — no safepoint's root set covers it. The comment should be corrected; it is the kind of claim that stops the next person looking.

Why CI did not catch it

gc-moving-witnesses on main for #7275 (run 30765414418) is green: loop_polls PASS 25/25, copy-minor 25/25 live. safepoint_minor — which the matrix describes as "what default becomes again when the stopgap lifts" — is in PR_ARMS and green.

So the gate ran, its subject was live, and it still cannot express this bug. The 25-file hand-written corpus is too small. A 20-line stock-zod program fails 5/40 while 25 curated files pass 25/25 — that gap is why the 30/30 number was believable. A dependency-scale witness is needed.

Asks

  1. Keep the stopgap. fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161 stays.
  2. Correct policy.rs:1408.
  3. Add a dependency-scale (real npm package) witness to the moving-GC corpus.

Activity

  1. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Sequencing warning before anyone starts this — see docs/src/internals/rfc-rooting-by-construction.md ("Interaction with the RSS goal", landed in #7292).

    Two findings change how this should be approached:

    1. The −65% RSS (320 MB → 111 MB) does not come from the copying minor. It comes from the 16 MB nursery cap, which merely happens to be gated on the same flag: effective_next_arena_trigger gates it on gc_scavenge_enabled() || gc_moving_loop_polls_enabled(). A no-poll arm reaches the same 108 MB. So there is a route to the memory win that does not require the risky moving path at all.

    2. Do not measure the cost of collecting more often until the minor path actually works. The "~20× wall-clock" figure attached to the cap was measured while gc-matrix: four of the six PR-gating arms run ZERO copying minors corpus-wide (default/verify_evac/cons_scan_off at copy-minor 0/49; header still claims 12/22) #7255's defect was live — in the default configuration gc_check_trigger falls through to a direct minor behind ManualGcScanGuard::force_full_scan(), i.e. a conservative full scan. Those were not minor collections. Minor-GC cost should scale with survivors, not with collection count, so a 20× penalty is a symptom, not a curve to sit on.

    ⇒ Fix the minor path first, then re-derive these numbers. Measuring now would bake the full-scan cost into the record and make an RSS/speed tradeoff look inherent when it is a defect. That conclusion is why this is sequenced last in the plan, not because the RSS goal is deprioritised.

  2. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Still red. #7161 cannot be reverted. Two independent efforts landed real fixes and the acceptance arm did not move:

    arm before after #7291 + #7299
    registry, POLLS=1 compiled and run 0/30 0/30
    stock-zod control, same ~34/40 ~31/40 (reads 31/32/34/35/35 across five builds — noise)
    registry / control, shipped default 30/30, 40/40 unchanged
    allocation-point arm, movement asserted 10/10 copied_objects>0 0/10 0/10

    What was fixed (real, just not sufficient)

    And the fault moved. After the parameter fix, the quarantine no longer faults at zod's clone — it faults at js_native_call_method + 580 from a module-init body: a receiver stale in a runtime frame, which is #7249's blind spot (layer 3 in docs/engine-plan.md), not something codegen rooting can reach.

    Two findings that should redirect effort

    1. 101 of 115 remaining violations are ONE unaddressed rule. #7284's checker fix took --moving-only from 65 → 115 on the gate corpus. The distribution: 55 property-GET-helper windows, 36 ToPrimitive/js_number_coerce, 18 js_closure_callN, 6 intra-corpus callees — but 101 of them are a single shape: a shadow-slot load cached in a register across a collection point. That is one measured change, not 101 fixes.

    2. The curated corpus is the wrong instrument. Running the same checker over the reproducer's own IR (86 modules, 65 MB) gives 77 fatal moving hits with a completely different distribution: 29 js_array_alloc → js_array_spread_append/concat, 14 js_box_get_bits → js_closure_callN, 8 js_object_alloc → js_object_set_field_by_name. The 25-file hand-written corpus does not represent real dependency code, which is the same gap this issue already records (25 curated files pass while 20 lines of stock zod fail).

    ⇒ Point the gate at dependency-scale IR before fixing another instance.

  3. 1 remaining item

  4. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Status on 8d27032c7: asks 1 and 2 are done, ask 3 is not, and the stock-zod control no longer reproduces

    Ask 2 is done

    policy.rs:1780 now reads:

    #7280: that used to read "so it is sound by construction". IT IS NOT, and …

    Ask 1 is honoured

    PERRY_GC_MOVING_LOOP_POLLS is still compile-time and still default-off.

    Ask 3 is NOT done

    gc-moving-witnesses.yml still drives scripts/gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_ — the same 25-file hand-written corpus this issue says is too small, and the job asserts arms == [("loop_polls", "move")] so nothing else runs.

    A dependency-scale corpus does now exist — scripts/gc_root_dominance_dep_corpus.sh (zod, ~90 native modules), wired at gc-root-dominance.yml:312. But that is a static IR corpus. There is still no dependency-scale RUNTIME witness on any moving-GC arm, which is what this ask was for.

    The stock-zod control does not fail any more

    I could not use the original file (it is not in the repo), so I rebuilt it from this issue's description — "20 lines, no scaffolding: import zod; build schema; safeParse ×96" — and then, because the reported fault was in zod/src/v4/core/util.ts::clone, built a second probe whose schema is made of the fluent modifiers that go through clone (.optional(), .nullable(), .default()), since the first one never calls any of them.

    probe arm result
    plain schema default compile / default run 40/40
    plain schema POLLS compiled AND POLLS at run (the revert config) 40/40
    plain schema POLLS compiled, plain run (the false-green control) 40/40
    clone-heavy schema POLLS compiled AND POLLS at run 40/40

    This issue measured 35/40 on that second row.

    Liveness, because "40/40" on its own is exactly the false green this issue is about

    PERRY_GC_ZEAL=1 with the POLLS binary:

    [gc-zeal] forced_collections=1144 copying_minors=1144 moved_objects=43799 loop_polls=1139
    ok 96
    

    So the poll codegen is in the binary (1139 back-edge polls fired), 1144 copying minors ran, and 43 799 objects actually moved — and the program is still correct. Under the pairing this repo names for #7154-class bugs, PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, the plain probe is 5/5 clean with 1144 retired quarantine sets per run. The clone-heavy probe under the alloc-pressure route plus the quarantine is 0/5 faults with the quarantine live 5/5; under zeal it accumulated 38 915 retired sets before hitting my 900 s timeout, still with zero faults.

    What that does and does not mean

    It means the specific stale deref this issue captured (util_ts__clone + 1964, obj_type=2 size=72, via js_typed_feedback_native_call_method_by_id) does not reproduce on a faithful reconstruction of its own control, under a configuration where the collector demonstrably moves tens of thousands of objects.

    It does not mean the revert is safe. My probes are reconstructions, and this issue's central argument — "a 20-line stock-zod program fails 5/40 while 25 curated files pass 25/25; that gap is why the 30/30 number was believable" — is untouched by two more probes that happen to pass. Ask 3 is the load-bearing one and it is still open, and it is now cheaper than when it was filed: the zod checkout the static dep-corpus script already depends on is exactly the input a runtime arm needs.

    Separately, and relevant to how this issue is verified: the from-space quarantine's usual knob set now prints retired_set= zero times on several workloads, because #7687 made allocation-point collections non-moving. Any arm added for ask 3 must assert retired_set=# per test, or it will report a clean run over a collector that never moved anything.

  5. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Closing — asks 1 and 2 are done and verified; ask 3 is refiled so the missing gate is tracked as work rather than as the tail of a mostly-closed ticket.

    Done: the stopgap is kept, and the overclaiming comment is fixed.

    Refiled: #7717 — the dependency-scale runtime witness for gc-moving-witnesses.yml, which is still absent. That issue carries this one's key evidence (loop-polls red 0/30, stock zod alone failing 5/40) and, more importantly, the acceptance criteria that stop it shipping vacuous: it must prove the collector ran ([gc-zeal] forced_collections/copying_minors, exit 70 when either is zero), compile with PERRY_GC_MOVING_LOOP_POLLS=1, and use a PERRY_GC_PROTECT_FROMSPACE_DEPTH sized to real code rather than the default 4.

    That last point is not theoretical — today on #7528 I ran the from-space quarantine and it printed zero retired_set= lines: no copying minor had run, so the instrument protected nothing while exiting clean. A witness that can do that is worse than no witness.

  6. added 6 commits that reference this issue on Aug 9, 2026
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions