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
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
bun run src/main.ts emit-ir /tmp/fx.milosucceeds, and so does the samePerry-built binary on 44 of milo's 47 examples — the three that fail all fail
this way.
A 312-line reduction of
std/fetch.milothat 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:
PERRY_GEN_GC=0PERRY_WRITE_BARRIERS=0PERRY_GEN_GC_EVACUATE=0PERRY_GC_VERIFY_EVACUATION=1Evacuation-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=1does notfire, consistent with "swept" rather than "moved and not rewritten".
PERRY_GC_DIAG=1on the failing run — a single cycle is enough to trigger it:Corroborating symptoms
'tag','name','span','read','attributes','isExtern'. It iswhatever field is touched first on the object that disappeared, not one
specific site.
console.errorinside thechecker moved the failure from
reading 'tag'toreading 'isExtern'— theextra allocations shift GC timing.
TypeChecker.checkStmtBodycheckingvar opts = FetchOptions { method: "DELETE", headers: "", body: "" }. At thatpoint
this.structs.get("FetchOptions").fieldsis intact (3 valid elements,dumped and verified), yet a neighbouring read off the same
infoobjectthrows — i.e. the map entry is fine and something else in the graph is gone.
What I ruled out
Map.prototype.setstoring a young value into a tenured Map — a targetedrepro (tenure a Map, insert 200 fresh entries, force minor GCs, read back)
shows 0 corruption on both perry and node.
Array.prototype.map(
s.fields.map(f => ({..., ...(cond ? {x:true} : {})})), the exact shape thatbuilds the
fieldsarray) — 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
main@ fd74751, macOS arm64