Skip to content

Differential fuzzer for the memory models: own and rc against gc - #646

Merged
ASDAlexander77 merged 2 commits into
mainfrom
mm-differential-fuzzer
Oct 22, 2026
Merged

ASDAlexander77 merged 2 commits into
mainfrom
mm-differential-fuzzer

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

What

tslang/test/fuzz/mm_fuzz.py is a hand-run differential fuzzer, like probe-corpus.sh. It generates small programs from the shapes that keep breaking -mm=own and -mm=rc, compiles each one under gc, rc and own (AOT, --opt --opt_level=3 --no-default-lib), runs them, and reports where a model disagrees with gc. gc is the reference. A program own rejects is fine. A program own accepts must print what gc prints, and so must rc.

The shapes come from the probes reviewers wrote by hand against own-raytrace. They are classes with object and array fields, @noinline factories, and let/const reads of fields and elements. They reassign locals, fields, globals and slots, also inside argument lists (f(n, (n = mk(), 0))), and pass one object twice (f(n, n), f(n, n.v)). Helpers keep, borrow or return a parameter on some paths. The rest are interface views and casts, bound methods, closures, Shared<T>, reduce, try/finally and break/continue. Every program churns same-size allocations before its final reads.

  • Own coverage: own accepts only about 4% of raw programs. When it rejects one, the lines it reports are deleted until it accepts, and that variant is checked too. That makes about 75% of seeds yield an own-accepted program, right at the edge of what own allows.
  • Failures: each one is confirmed, then minimized by delta-debugging over lines and blocks (a run is retried up to 3 times, since a stale read doesn't always land on a reused block). It's written as a standalone repro.ts with asserts on gc's values, beside each model's output. --recheck-tslang also runs the repro with a second compiler (main vs a branch) and records whether it fails there.
  • --avoid arg-assign,globals: switches off a shape whose bug is already known, so it stops hiding the rest.
  • seeds/: the 30 reviewer probes that still failed rc on main or own on own-raytrace; replay runs them.
  • On Linux it sets MALLOC_PERTURB_, so freed memory is filled and double frees abort; the Windows heap hides those.

Results

About 15,000 programs across Windows and WSL Linux, against main 4e1599d, own-raytrace (ca14abf8 on Linux, a 0bf9ed7f+WIP snapshot on Windows) and the #633 merge-base. The tool found 13 distinct bugs. 12 are in own/rc, and one is a gc/none closure bug that makes gc a wrong reference in one shape. One of them is a regression from #639, bisected. The full triage report is in the session notes and isn't part of this PR. No compiler code changes here.

Testing

  • mm_fuzz.py replay over the 944 reviewer probes. It classifies review8r3/L5.ts as rc crash, own rejected, gc 501500, matching what was seen by hand.
  • 3000-seed runs on Windows and Linux. gc compiles 100% of the generated programs; the 11 gc run failures in the first run were a generator bug (a closure calling itself), now fixed.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 22, 2026 23:37
tslang/test/fuzz/mm_fuzz.py generates small programs from the shapes
that keep breaking -mm=own and -mm=rc: classes with object and array
fields, @noinline factories, const/let reads of fields and elements,
reassignment of locals, fields, globals and slots (also inside an
argument list), one object passed twice, helpers that keep, borrow or
return a parameter on some paths, interface views and casts, bound
methods, closures, Shared<T>, reduce, try/finally and break/continue.
Every program churns same-size allocations before its final reads.

Each program is compiled under gc, rc and own (AOT, --opt
--opt_level=3 --no-default-lib) and run; gc is the reference. A
program own rejects is also checked in an own-accepted variant, made by
deleting the lines own reports. Failures are confirmed, minimized
(delta-debugging over lines and blocks) and written as a standalone
repro with asserts on gc's values, the outputs of each model, and
optionally the verdict of a second compiler (--recheck-tslang).

seeds/ holds the reviewers' own-raytrace probes that still failed on
main (rc) or own-raytrace (own) when this was written; `replay` runs
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ue alike when minimizing

A stale read lands on a reused block only some of the time, so a
minimizer that judged each candidate by one run gave up on its first
check. Candidates and the final checks now run the binary up to three
times, and a wrong value and a crash both count as the same failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit 50ef536 into main Oct 22, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the mm-differential-fuzzer branch October 22, 2026 23:11
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…global a count (#647)

Two shapes the memory-model fuzzer (#646) found double-freeing under rc
since #639.

- holdGlobalArguments retained the argument right before the call. A
  later argument of the same call could give the global a new value
  first (`f(G, (G = mk(), x))`, `f(H.n, (H.n = mk(), x))`): its release
  freed the block, and the hold then retained and released a freed
  block. The retain (and the #645 unwind slot) now follow the read.
- `const n = G` was folded into the read, holding no count. Once the
  global got a new value, here or in a call, `n` dangled (fuzzer R1).
  #639 made that a crash: `run(n.v)`, with `run` assigning G, held and
  released a field of the freed block. Under rc a const read out of a
  global, or an interface or bound method made over one, now gets
  storage like a const read out of a field or an element (#635, #641,
  #642, #644). Under own the folded read stays a borrow.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
A global, or a global's field, given to a call as a view - an interface,
a base class, an object literal holding it - and given a new value by a
later argument of the same call (`f(G, (G = mk(), x))` with an interface
parameter, `f({ v: G.v }, (G = mk(), x))`) was freed before the call ran.

The view is made at the call, after every argument is evaluated, and
holdGlobalArguments did not see through it: `globalOf` stopped at
`ts.NewInterface`, and an object literal holds what it was given with no
count until it is boxed for the interface, at the call too. Under rc the
hold now keeps the value behind the view - the object a cast or an
interface is made over, or each value stored into the literal's slot -
retained where it is read (#647). The fuzzer (#646) found these as A1's
view variant; they failed on #633 as well.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
`f({ v: n.v }, (n = mk(), 0))` and `f(n.items[0], (n = mk(), 0))`: the
argument is read out of a field or an element of `n`, and the later
argument's assignment to `n` gives back the object it was read out of.
overwrittenBeforeCall looked only at the argument's own place; it now
walks out through the fields and elements it was read through, and
holds the argument when any of those values' places is overwritten
before the call. Found by the fuzzer (#646) past the first 1000
programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
`for (..) { const v = mv(); try { try { continue; } finally { break; } }
finally {} }`: the temporary was released in front of the `continue`,
and again in front of the `break` its finally then took. The release
for a jump that passes through a finally now always goes at the end of
the outermost such finally, tagged with the jump, not only when the
finally reads the temporary: a finally that leaves by a jump of its own
never reaches it, and the jump's own release is then the only one.
Fuzzer (#646), found past the first 1000 programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
A new array is born at count 0 under rc, and one built for an argument
(`g([mk(1)])`) had no other owner. A count the callee took and gave back
freed the array the callee still read: an alias in a loop or a try
(`const a = p`), or the parameter assigned, which copies it into a local
that owns it. Nothing ever gave such an array back either.

holdGlobalArguments now also holds an array made for the call (a new
array, or a constant array copied to the heap by its cast, used only by
the call): retained where it is made, released after the call. Fuzzer
(#646) R3 / R3b.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…it (#652)

A local declared with no initializer (`let n: N;`), or in a catch or
finally clause, owns nothing (localTakesOwnership): it takes no count
for what it is given. A call's result stored into one was still a
discarded temporary, released at the end of its own block - before the
local's scope ends when the store sits in a nested block:
`let n: N; if (c) { n = mk(); } use(n)`, or a loop in a finally that
assigns a local declared outside it. The local then read a freed block.

releaseDiscardedTemporaries now leaves such a value alone when the
local is declared outside the value's block: a leak, the side this arc
keeps the uncertain case on. Fuzzer (#646) R9; the no-initializer
shape was found while reducing it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
* rc: a temporary a finally reads is released after the finally

`const v = mv(); try { if (c) continue; } finally { s += v.get(); }`: a
folded const (a call's result named by a const) is released where its
block ends, and in front of each break, continue or return that leaves
the block early. That release ran before the finally the jump goes
through, and the finally then read a freed block. #600 fixed the same
for locals in slots; a folded const is released by
releaseDiscardedTemporaries instead.

When a finally between the jump and the temporary's block reads it, the
release now goes at the end of the outermost such finally, in a ts.If
tagged with the jump (JUMP_ONLY_ATTR_NAME, RETURN_ONLY_ATTR_NAME), so
TryOpLowering keeps it in that jump's copy of the finally alone - the
scheme MLIRGen uses for the scopes the jump leaves. Jumps with one key
share one copy and get one release. Fuzzer (#646) R6, rc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rc: a jump through a finally that jumps itself releases a temporary once

`for (..) { const v = mv(); try { try { continue; } finally { break; } }
finally {} }`: the temporary was released in front of the `continue`,
and again in front of the `break` its finally then took. The release
for a jump that passes through a finally now always goes at the end of
the outermost such finally, tagged with the jump, not only when the
finally reads the temporary: a finally that leaves by a jump of its own
never reaches it, and the jump's own release is then the only one.
Fuzzer (#646), found past the first 1000 programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
* rc: hold an argument whose place the call overwrites

An argument is borrowed, and two shapes overwrote the place it was read
from while the call still used it:

- a later argument of the same call assigns it: `f(n, (n = mk(), n))`
  for a local, a parameter or a local's field (A1). #647 covered a
  global; a local was still freed before the call ran.
- the callee is given the object as well: `f(n, n.items[0])`, where `f`
  overwrites `p.items[0]` and then reads its other parameter (R8).

Under rc holdGlobalArguments now also holds an argument read out of a
slot or a place that something between the read and the call
overwrites, and an argument read out of a field or an element of a value
given as another argument of the call: retained where it is read,
released after the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rc: hold an argument read out of a value a later argument reassigns

`f({ v: n.v }, (n = mk(), 0))` and `f(n.items[0], (n = mk(), 0))`: the
argument is read out of a field or an element of `n`, and the later
argument's assignment to `n` gives back the object it was read out of.
overwrittenBeforeCall looked only at the argument's own place; it now
walks out through the fields and elements it was read through, and
holds the argument when any of those values' places is overwritten
before the call. Found by the fuzzer (#646) past the first 1000
programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
… a count (#651)

`const v = ix.v; ix.v = mv();` and `const v = GS.value; GS.value = mv();`
freed what `v` still read: the const was folded into the read, which
holds no count. #635 gave a const read out of a class field storage
under rc; a read through an interface (ts.InterfaceSymbolRef) or out of
a Shared<T> handle (ts.SharedValueRef) is a field read as well, and now
gets the same. Under own the folded read stays a borrow, rejected when
the place is overwritten. Fuzzer (#646) R2 and R7.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
`a[0] = f()` with `f` growing `a`: the element's address was taken while
the left side was read, before the right side ran, and growing moved
the elements to a new block - the store went into the one given back.
The value was lost under every model, and under rc a freed block was
written to (fuzzer #646, 6930). The assignment now takes the element's
address again once the value is ready, from the array and the index
the left side evaluated first, as JavaScript has it: a right side that
assigns the variable itself (`a[1] = (a = [5], 9)`) still stores into
the old array.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…#654)

`const v = n.v; n.v = d; const c = () => v.x;` gave `d.x` under gc and
none. A const read out of a field, an element or a global is folded
into the read and has no storage of its own (under rc it does, see
processConstRef). A by-value capture was taken through the reference
GetReferenceFromValue found for it - for a folded read, the place it
was read from - and CaptureOpLowering dereferenced that place when the
closure was made, after the overwrite.

A capture now goes through a reference only when that reference is the
variable's own storage (a variable, a parameter, a captured cell);
anything else captures the value itself. Fuzzer (#646) G1.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…, counts its object (#659)

* rc: a method bound through an interface, or by a global's initializer, counts its object

Under rc a bound method counts the object it is bound to through that
object's type tag, and only the bound value's receivers take a count
(getBoundObjectTypeTag). Two holes:

- bound through an interface (`c = ig.get`), the object's type is not
  known where the bound value is built, so it got no tag and counted
  nothing: `c()` ran on a freed object once the interface went. It now
  takes the tag the interface carries for what it points at (null for a
  missing optional method).
- a global initialized with a bound method (`let c = mk().get`,
  `let c = G.v.get`) took no count: a temporary object was given back
  at the end of the initializer, and the first `c = d` released it a
  second time. The global now takes one, as it does for a field read.

Fuzzer (#646), found past the first 1000 programs (40181).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rc: build a method bound through an interface from LLVM values

The tagged bound value was an UnrealizedConversionCast over a
ts.CreateBoundFunction still waiting for its own conversion. Under
--opt that corrupted the compiler's heap on Linux (36interfaces,
00owned_construct_interface). Build the struct directly, as
CreateBoundFunctionOpLowering does: method, this, then the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…other name (#663)

`f7((p12.n = mk(7), p12.n), (HG.n = mk(9), p13))` with `p12` being
`HG`: the first argument is read out of `p12.n`, and the second
argument's `HG.n = mk(9)` gives back the block it read. The caller
holds an argument whose place is overwritten between the read and the
call (#653), but samePlace compared places by name. A store to one
field of another object of the same class, or to an element of another
array of the same type, may be the same place, and now holds the
argument as well. Fuzzer (#646), 80037.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
`const c: () => number = G.items[0].get`: the declared type casts the
bound method to a hybrid function, and viewedValue did not look
through that cast, so processConstRef saw no bound method over a read
of a field, an element, a global or a local. The const was folded with
no count, and `G = mk()` freed the object `c()` still ran on.
viewedValue now strips casts first. Fuzzer (#646), 80194.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant