Repository navigation
Differential fuzzer for the memory models: own and rc against gc - #646
Merged
Merged
Conversation
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
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>
This was referenced Oct 23, 2026
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>
This was referenced Oct 23, 2026
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>
This was referenced Oct 23, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
tslang/test/fuzz/mm_fuzz.pyis a hand-run differential fuzzer, likeprobe-corpus.sh. It generates small programs from the shapes that keep breaking-mm=ownand-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,
@noinlinefactories, andlet/constreads 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/finallyandbreak/continue. Every program churns same-size allocations before its final reads.repro.tswith asserts on gc's values, beside each model's output.--recheck-tslangalso 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;replayruns them.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 replayover the 944 reviewer probes. It classifiesreview8r3/L5.tsas rc crash, own rejected, gc 501500, matching what was seen by hand.🤖 Generated with Claude Code