Skip to content

GC: live object swept during minor GC — missing write barrier (PERRY_WRITE_BARRIERS=0 / PERRY_GEN_GC=0 both fix it) #6892

Description

@proggeramlug

Summary

A long-lived object graph loses a live reference during a minor GC: a field that held a valid object reads back as undefined. Disabling either the generational GC or write barriers makes it go away, so this looks like a missing/ineffective write barrier rather than a logic bug.

Reproducer

git clone https://github.com/milo-language/milo
cd milo && bun install && bun run scripts/bundle-stdlib.ts
perry src/main.ts -o milo-perry

printf 'from "std/fetch" import {}\npub fn main(): i32 { return 0 }\n' > /tmp/fx.milo
./milo-perry emit-ir /tmp/fx.milo
TypeError: Cannot read properties of undefined (reading 'tag')

bun run src/main.ts emit-ir /tmp/fx.milo succeeds, and so does the same
Perry-built binary on 44 of milo's 47 examples — the three that fail all fail
this way.

A 312-line reduction of std/fetch.milo that still reproduces (down from 598,
delta-debugged with the predicate "bun exits 0 AND perry throws") is attached in
the linked comment.

The GC signal

Same binary, same input, only environment differs:

mode result
default throws
PERRY_GEN_GC=0 passes
PERRY_WRITE_BARRIERS=0 passes
PERRY_GEN_GC_EVACUATE=0 throws
PERRY_GC_VERIFY_EVACUATION=1 throws (no verifier panic)

Evacuation-off does not help but barriers-off does, which points at a
liveness problem rather than a relocation one: a nursery object reachable
only from an old-gen slot is not in the remembered set, so a minor GC sweeps it
and the old-gen slot is left dangling. PERRY_GC_VERIFY_EVACUATION=1 does not
fire, consistent with "swept" rather than "moved and not rewritten".

PERRY_GC_DIAG=1 on the failing run — a single cycle is enough to trigger it:

[gc] blocks: general=47 (47 live), longlived=4 (2 live), freed_bytes=34544640
     retained_forwarded_stub_bytes=176704 retained_forwarded_stub_objects=140

Corroborating symptoms

  • The property name varies across runs and phases on the same input:
    'tag', 'name', 'span', 'read', 'attributes', 'isExtern'. It is
    whatever field is touched first on the object that disappeared, not one
    specific site.
  • It is instrumentation-sensitive. Adding a console.error inside the
    checker moved the failure from reading 'tag' to reading 'isExtern' — the
    extra allocations shift GC timing.
  • Narrowed to TypeChecker.checkStmtBody checking
    var opts = FetchOptions { method: "DELETE", headers: "", body: "" }. At that
    point this.structs.get("FetchOptions").fields is intact (3 valid elements,
    dumped and verified), yet a neighbouring read off the same info object
    throws — i.e. the map entry is fine and something else in the graph is gone.

What I ruled out

  • Map.prototype.set storing a young value into a tenured Map — a targeted
    repro (tenure a Map, insert 200 fresh entries, force minor GCs, read back)
    shows 0 corruption on both perry and node.
  • Object-literal conditional spread inside Array.prototype.map
    (s.fields.map(f => ({..., ...(cond ? {x:true} : {})})), the exact shape that
    builds the fields array) — byte-identical to node.

So the missing barrier is on some other store; I did not find which one.

Impact

Last known defect stopping the Milo compiler
(https://github.com/milo-language/milo) from being fully usable under Perry.
After #6870/#6871/#6873/#6879/#6887 it builds and runs, with 44/47 examples
producing byte-identical LLVM IR; the 3 remaining failures are all this bug.

Because the trigger is GC timing on a large object graph, the blast radius is
not limited to this program — any sufficiently large workload could hit it.

Environment

  • perry main @ fd74751, macOS arm64

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