Reference counting memory model (-mm=rc) and related codegen/runtime fixes - #308
Merged
Merged
Conversation
Evaluates reference counting as a selectable memory model alongside Boehm GC for compiled TypeScript output. Covers what today's GC integration relies on, the ABI/mixed-linking risk of a per-module memory-model choice, cycle handling as an opt-in tradeoff, and a staged implementation order starting with a mode-neutral object header change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Allocations through _MemoryAlloc now reserve a leading pointer-sized word and return a pointer to the payload past it; _MemoryRealloc and _MemoryFree convert back to the block base. The word is never read: this establishes the block layout a reference-counting memory model would need, while GC remains the only model and the header stays inert. Keeping the layout identical across memory models is what would make a GC-built module and an RC-built module safe to link together, which is the one decision in that work that cannot be retrofitted. See docs/reference-counting-evaluation.md sections 4 and 9.1. The typed-class path (GC_malloc_explicitly_typed) is deliberately left alone: its Boehm descriptor indexes bits relative to the object base, so moving the base without shifting the generated bitmap would make the collector trace the wrong words. Zeroing now covers the whole padded block rather than just the payload, which keeps the memset's first operand the allocation call itself so GCPass::removeRedundantMemSet still recognises and drops it. Full release test suite passes, including the array push/splice, string mutation and delete paths that exercise all three modified helpers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Path 1 landed green. Notes which helpers the suite actually exercised, that the realloc/free provenance risk did not materialise, and that the WASM allocator path remains unvalidated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…w use generic allocation path
The tag stored in an "any" box and in a tagged union was a bare type-name
string, under a standing "TODO: add type id to track data type". A name is
not enough to dispatch on: every class reports "class", so it erases exactly
the distinction a per-type routine would need.
The tag cannot simply stop being a string, though - it *is* the `typeof`
result. GetTypeInfoFromUnionOp hands it straight to `typeof`, MLIRGen
strcmp's it against "class" to implement `instanceof` over `any`, and the
generated union operator helpers compare `typeof(r) == "class"` in source
text.
So the record moves in front of the tag instead. Each distinct type gets one
static
{ { i32 kind, i32 reserved, ptr release }, [N x i8] name }
and the tag is the address of `name`. Existing consumers keep reading a
NUL-terminated type name unchanged; anything wanting the record takes
`tag - sizeof(record)`, which folds to a constant GEP. The trailing name is a
byte array, so it is never padded and that offset is the record size on every
target - the same header-in-front-of-payload shape as the heap block header.
Descriptors are keyed by the concrete type rather than the name, so two
classes get two records. typeOfBaseType strips the wrappers typeOfAsString
already sees through, keeping one "string" record instead of one per string
literal type.
Falling out of it: asking whether an `any` operand is numeric ran nine
strcmps per operand, because typeOfAsString reports "s32"/"f64" and only says
"number" for float-typed values. That is now one load and one compare, and it
covers every numeric width rather than the nine that were spelled out. The
width dispatch in unboxNumericAsF64 stays name-based, since the kind says
"numeric" and the width is what decides how many bytes to read back.
TYPE_DESCR_* is a cross-module contract even though the records have internal
linkage, because a tag produced by one module is read back by another. The
release slot is reserved now for that reason, not because anything calls it.
Full release suite green: 829/829, including 106 cross-module tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each type that owns heap memory now gets one routine that releases what it
owns, and its address fills TYPE_DESCR_RELEASE. Nothing calls them; the
descriptor reference is also what keeps them from being dead-stripped.
The plan said "from the existing bitmap machinery", but that machinery was
the unsound generator already retired for computing a pointer layout at run
time - which is where all three of its defects came from. A type's pointer
layout is knowable at compile time, so these are emitted as straight-line
code with the offsets baked in.
A routine takes a pointer to the storage holding a value rather than the
value, which is uniform across value categories and makes releasing a field a
GEP plus a call. string frees its block; array<E> loops its length calling
E's routine then frees the data block; a class or object releases its storage
fields then frees the instance; a tuple releases fields and frees nothing,
because its storage belongs to whoever holds it.
"any" and tagged unions read the release routine out of the descriptor their
tag points into, so a value whose type is only known at run time still
resolves to one. That is what the previous commit was for.
Recursion works because the symbol exists before its body is built, so
`class Node { next: Node }` emits a routine that calls itself. Interfaces,
function types, RefType and const data are deliberately left with a null
slot, which says "nothing to release" rather than "unknown"; each reason is
recorded at needsRelease.
Writing the string routine surfaced a prerequisite that reorders the plan: a
string literal compiles to `store ptr @s_..., ...`, so a `string` field can
hold a pointer into a read-only global that no allocator produced, and
freeing it would corrupt the heap. Static strings need the same block header
with an immortal marker before strings can be released - which is exactly the
first shipping scope the evaluation recommends. Added as step 4a; not done
here, since it touches every string literal on a hot path.
Full release suite green: 829/829.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A string literal compiles to `store ptr @s_..., ...`, so a `string` value was two different shapes depending on where it came from: a heap payload with a header in front of it, or a raw pointer into a read-only global. Nothing at run time could tell them apart, which is what stopped the previous commit's string release routine from being callable - freeing `@s_... - headerSize` corrupts the heap. Every global string now carries the same header word, set to HEAP_BLOCK_IMMORTAL, and __tslang_free_block skips a block that says it is immortal. The marker is all-ones bytes, so it reads as -1 whatever the word size or endianness, and the global stays a plain [N x i8] with a StringAttr initializer - no initializer region, and the existing seekLast<StringAttr> placement still works. Aligned to the header size so the word can be read as a word. Deliberately not zero, which is what a fresh heap block reads. Every global string gets it, not only the ones that could be released. Deciding per call site which getOrCreateGlobalString produces a TypeScript string rather than a printf format or a symbol name would be an audit whose failure mode is silent corruption, in exchange for eight bytes per constant. The "true"/"false" globals a boolean cast returns are a good example of a site that does not look like a string value but is one. That surfaced a second hole. `typeof x` returns a pointer into a type descriptor, and `let s: string = typeof x` is ordinary TypeScript, so a tag is a releasable string too - but the descriptor's name had only the release field in front of it, which would have read as a very mortal-looking count. The record now ends with the block header, immediately before the name, so both reads work off the same pointer: `tag - sizeof(header)` is the marker, `tag - sizeof(record)` the record. Nothing writes the header on allocation, because nothing maintains a count yet, so the test is meaningful for static blocks and says nothing useful about heap ones. That half belongs with maintaining the count. The static half is separated out here because it is the half that changes an ABI and so cannot be retrofitted. Full release suite green: 829/829. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two pieces: the last outstanding item of the mixed-link problem, and the design decision that had to precede any weak-reference code. A shared library now records the model it was built under as an exported data symbol __tsmm_<model>_<file>_<hash>. The model is in the name, so an importer reads it during the symbol enumeration it already performs and never loads the data. Deliberately not __decls-prefixed, so it can never reach the declaration re-parser, whose enumeration is prefix-driven. A library with no marker predates this and everything collected back then, so a missing marker reads as gc. Both spellings come from one memoryModelName(), so the -mm= flag and the marker cannot disagree about what a model is called. On a mismatch the import is allowed and warns, which is the agreed policy: leak what crosses rather than free it twice. Verified end to end - a DLL built -mm=gc carries __tsmm_gc_export_vars_<hash>, importing it -mm=gc is silent, importing it -mm=rc reports both models and still runs. Nothing marks crossing objects immortal yet; that lands with ownership insertion, and until a release actually frees, a mixed link is harmless. The mismatch path has no automated test, since the 106 cross-module tests all build both sides the same way and giving the runner a per-side model is more plumbing than one warning is worth - the marker's presence is covered by all of them, which is the part that could break something. The doc also settles weak references, on paper, because the header layout they imply is ABI. Spelling them WeakRef<T> with .deref() reuses the type JavaScript already has, so no parser change and no new syntax, and the semantics come out stronger than JS's in a compatible way: undefined exactly when the last strong reference went. The objection to storing a weak count - that a uniform header would force it on gc builds too - dissolves once the header grows downwards: strong sits immediately before the payload in every model, weak before that and only under rc. Strong is the only field a cross-model write touches, so uniformity is preserved where it is needed and gc builds keep their single word. Full release suite green: 847/847. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… release routines
Ownership is now sayable in the dialect, ahead of deciding where to say it. Nothing emits either op yet, so this is inert. The ops erase under any model that is not reference counting. That is the decision the rest follows from, and it is what makes "RC is an option" hold in the code rather than as an aspiration: MLIRGen can state ownership once, unconditionally, with no isRefCounted() branching through it, and the lowering decides whether it costs anything. It also reshapes the risk of the step after this one. Ownership insertion is where a mistake stops being inert - a missing retain frees live memory, an extra one leaks - but a misplaced op is erased in a collected build. The ~830 GC tests are therefore structurally immune to insertion bugs rather than merely expected to pass, and only the 17 -mm=rc tests can break. Retain is not the mirror image of release, and that asymmetry is the whole difficulty. Retaining a reference stops at the block it names: a second reference to an object does not duplicate that object's own references to its fields. Release does walk the fields, but only inside emitIfLastReference - only when the block is about to die and its fields' references die with it. What propagates a retain inwards is a value held inline - a tuple, an optional, a tagged union - because copying one really does duplicate every reference it holds. Backwards in either direction leaks or double-frees, and neither shows up until a count is wrong much later, so the two builders sit next to each other with the reasoning written between them. ReleaseRoutineLogic became OwnershipRoutineLogic for that reason. __tslang_inc_ref skips an immortal block, which is not an optimisation: incrementing all-ones gives zero, and the next release would read that as the last reference and free a string literal. The descriptor record grew a retain slot beside the release one, for the same reason the release slot exists - a tagged union carries its payload inline, so copying one has to retain a value whose type is only known at run time. The block header stays last, immediately in front of the name bytes, so a tag still reads as an immortal string payload; the name moved from offset 24 to 32. Verified by reading the emitted IR under both models: a retain routine loads the reference and calls __tslang_inc_ref with no field walk, so the asymmetry holds in the generated code and not only in intent. Temporarily emitting both ops at the delete site produced tsretv_/tsrelv_ calls under -mm=rc and nothing at all under -mm=gc, where only GC_free remains; the hook was then reverted. Full release suite green: 847/847. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Step 5a: the first slice of ownership tracking, and the first time the compiler calls a retain or a release on its own account rather than because the program said `delete`. A local variable declaration whose type owns heap memory takes a reference when it is declared and gives it back at every exit from its scope - the block's end, a return from anywhere inside it, a break or continue that leaves it. Assigning through such a local hands the count over: the incoming value gains this scope as an owner and the outgoing one loses it. MLIRGen never asks which memory model is in force. It emits ts.RetainSlot / ts.ReleaseSlot and the lowering decides, so a collected build shows no trace of any of this - verified by reading the emitted LLVM for the same file under both models, where the gc output is instruction-for-instruction what it was before. That is what the new slot-addressed pair buys over the value-addressed ts.Retain/ts.Release: the slot form erases whole, taking the access with it, where a value-taking release would have needed a load kept alive under a collector just to have an operand. Balanced by construction, which is the property that makes this safe to land first. The reference an allocation is born with is never given up here, so every release is paired with a retain this step emitted and nothing can be freed early. What it can do is leak, and under -mm=rc the collector is still what reclaims, so the leak is inert. That direction is deliberate: an over-release is a use-after-free surfacing far from its cause, and a leak is not. Removing the slack - consuming the +1 when the initialiser is a fresh allocation, retaining on field and element stores, releasing temporaries - is later work, and each piece is a separate decision. A local is not made an owner when the frame borrows the reference rather than owning it: globals, parameters, captured variables held in the `this` context, const bindings with no storage, and declarations with no initialiser. That last one is the single bug this step produced. A `catch (v: string)` variable is declared like any other let but written by the landing pad, so retaining at the declaration read an uninitialised slot as a live reference and trapped. It was the only failure out of 849, which is exactly the blast radius the erasure rule predicts - the collected tests cannot be broken by where these ops land, only the counted ones can. The unwind leg deliberately skips the releases: an owned local's storage is allocated inside the TryOp body region, which does not dominate the cleanup region, so a release there would not verify. Disposal still runs there. Fixing the leak means hoisting owned storage the way allocateUsingVarsOutsideOfOperation already hoists `using` variables, and belongs with the verifier. Hooks into three points that already existed: takeOwnershipOfLocal beside where usingVars is collected; mlirGenScopeExit wrapping mlirGenDisposable and the new mlirGenReleaseOwned, so all eleven existing scope-exit call sites got the releases for free - disposal first, since a disposable is still usable while its [Symbol.dispose]() runs; and mlirGenSaveLogicOneItem, the single choke point every assignment form passes through, where retaining before releasing is what makes `x = x` safe. ownsHeapMemory moves from OwnershipRoutineLogic to MLIRTypeHelper so both sides ask one function - the two disagreeing about which types own memory would place retains that never pair with a release, which is the failure mode with no local symptom. New test: test/tester/tests/00owned_locals.ts, run under all three models. Beyond one local of each owning shape it covers the paths that reach a slot without going through an assignment expression, since that is where a missing retain would turn into a release of a reference nobody took: for-of bindings, destructured declarations and destructured assignment, a captured local, break/continue out of a loop, a return out of a nested block, and returning a value the caller is about to own. A 2000-iteration churn loop makes an early free likely to be handed straight back out rather than silently tolerated. Full release suite green: 852/852 (849 existing + 3 new). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four conditions added with the synthesized cleanup TryOp each kept
it away from a shape that crashed, and each shape really did crash. But
all four were observed before that same change's own unwindDests fix
landed - the cleanup-only TryOp pushing a null Block * - and that one
fix turned out to be the cause of more of them than the notes credited.
Re-running every guarded shape against the current build: a `using` in
an if-block, in a loop body, two scopes deep, and one sharing its
function with a `return` all dispose correctly on the way out now, where
before the dispose was silently skipped. Nested TryOps, recorded as
crashing LLVM translation, compose fine - try/catch inside try/catch,
and a synthesized cleanup inside a hand-written try, both verified.
So blockIsFunctionRootBody and blockHasReturn are gone. Dropping the
root-body condition is the one that matters: synthesis is no longer
confined to a function's own top-level body, which is what makes the
four shapes above work.
blockUsingInitializersAreAllNewExpr and blockHasNestedUsing stay, and
each was confirmed individually necessary by dropping it alone and
rebuilding rather than inferred from a combined result: without the
first an object-literal disposable fails the build, without the second
an outer `using` whose block also holds a nested `using` scope segfaults
the compiler. Their comments now say what was reproduced instead of what
was inferred.
The gate was made maskable by an environment variable for the duration
of the experiment, so one build could test all sixteen combinations.
Removed before this commit.
Also found, and not fixed: throwing from inside a catch clause crashes
the LLVM backend. Reduced to `try { throw 1; } catch (e: int) { throw
2; }` with no using, no locals and no heap types anywhere in it, so
nothing in this branch is involved. Written up in the doc; no test
covers rethrow-from-catch, which is why nothing caught it.
New test: test/tester/tests/04disposable.ts, covering the four
newly-working shapes plus the two exact-count cases that would catch a
double dispose - a function that throws past a `using` on one path and
returns past it on the other, and a synthesized cleanup nested inside a
hand-written try.
Full release suite green: 854/854.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`try { throw 1; } catch (e: int) { throw 2; }` crashed the compiler, and
so did the `throw e` rethrow idiom. One missing line, but the shape is
worth recording.
ThrowOpLowering ends with clh.CutBlock(), which drops everything after
the throw in its block - including the EndCatchOp that TryOpLowering had
placed just before the region's terminator. Win32ExceptionPass then
finds a catch region with no end marker, picks one itself by splitting
the block ahead of the throw, and emits the catchret there. The result
is a catchret followed by a call still carrying "funclet"(token
%catchpad), naming a funclet it has already returned from. That reaches
the backend and crashes it.
ReturnOpLowering, BreakOpLowering and ContinueOpLowering all emit an
EndCatchOp before leaving a catch. ThrowOpLowering was the only abrupt
exit that did not.
It needs its own side table rather than the existing one. The other
three say "I am leaving a catch" by having tsContext->unwind set; a
throw cannot, because for a throw that map already means its invoke
destination and the finally handling writes exactly that into it.
And only when there is no finally. With one, the throw becomes an invoke
into the finally block and the finally is what ends the catch - ending
it at the throw as well runs it twice and breaks the unwind.
51exceptions.ts is the case that proves it, and it caught the first
version of this fix.
Two regressions of my own turned up while testing this, both from the
same gap in coverage - no test had a declaration inside a catch clause -
and both fixed here by one new predicate, blockIsInsideCatchOrFinally:
- Dropping blockIsFunctionRootBody in the previous commit also stopped
excluding catch and finally regions, and synthesizing a cleanup TryOp
in one crashes the compiler: `catch (e: int) { using r = new Res(); }`
segfaults with the wrapping and compiles without it. That commit's
matrix checked nesting inside a try body and never inside a catches
region. The four shapes it fixed all still work.
- Under -mm=rc a release in a catch clause is a call inside a funclet,
which is exactly the fragile construct below, and
`catch (e: int) { let r = new Res(); }` segfaulted. Locals declared
in those clauses are no longer owned: they leak, which the collector
still reclaims - the trade every other exclusion there makes.
04disposable.ts now covers a `using` in a catch and in a finally, and
03/04disposable gained -mm=rc variants, which is what would have caught
the ownership half.
Still open, each confirmed independent of this fix:
- An exception escaping a catch clause is lost under AOT, and always
was. A call in a catch that throws loses it too, with no `throw`
statement involved anywhere, so the gap is in the AOT exception
tables rather than here; the IR is well-formed at -O0 and -O3. The
new test is registered JIT-only for that reason.
- A call inside a catch followed by a throw out of it crashes at run
time, AOT and JIT alike. Its IR is well-formed too.
- Throwing from a finally segfaults, from the same CutBlock cause -
ts.BeginCleanup with no ts.EndCleanup. Not fixed: EndCleanupOp is a
terminator taking a landing pad and unwind destinations rather than a
marker, and the finally region is cloned once per exit path, so each
copy would need its own.
New test: test/tester/tests/00throw_in_catch.ts.
Full release suite green: 858/858.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5b, and it stops one line short of its goal for a reason that has nothing to do with reference counting. Storage for a local that owns a heap reference is now hoisted out in front of the TryOp, exactly the way `using` storage already was, so it dominates the cleanup region as well as the body. The flag serving both is renamed accordingly. The decision cannot live in detectFlags with the others - it needs the variable's type, which is not known until createLocalVariable - and it goes through localTakesOwnership, shared with takeOwnershipOfLocal so the two cannot disagree: a local that is hoisted but not owned only wastes a move, but one that is owned and not hoisted puts a release in a region its slot does not dominate. A hoisted slot leaves its initialising store behind at the declaration, and the unwind edge can reach the cleanup before that store runs, so under -mm=rc it starts as null - the one value every release routine treats as nothing to do. Gated in the lowering, not in MLIRGen. The release itself still does not run on the unwind leg. Turning it on makes exactly one test fail, and the cause is a JIT-only Win64 unwind defect that predates all of this: a `using` in a try body plus a catch clause making two calls corrupts a callee-saved register in the caller. Reproduced on the parent commit under -mm=gc with no ownership involved; correct AOT and at -O0. The release raises register pressure in the cleanup funclet and pushes more programs across that line. 00try_using_catch.ts pins the shape as correct when compiled and is registered AOT-only. 859/859 green. Doc section 9.15 has the dump analysis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…T mode - Updated catchable type size definitions to ensure correct memory handling. - Added tests for JIT and AOT scenarios to validate the fix. - Improved documentation regarding the changes and their implications.
Two of section 9.11's guards turned out to be standing in front of the
same defect, in a place neither of them named. Both of these crashed the
compiler in every memory model:
try { if (f) { using r = new Res(); throw 1; } } catch (e: int) { }
using a = new Res(); { using c = new Res(); } throw 1;
Win32ExceptionPass::ToInvoke exists to turn a call into an invoke with a
given unwind destination, so it splits the block at the call to make
room for the new terminator. But the "fix incorrect landing pad" loop
hands it operations that are already invokes, and an invoke already ends
its block: splitting at one puts it alone in the new continuation block,
which every caller then erases it from. What is left is an empty block
with no terminator, and the real continuation stranded with no
predecessors:
%invoke = invoke void %24(ptr %23) [ "funclet"(token %cleanuppad) ]
to label %invoke.cont unwind label %26
invoke.cont: ; preds = %15
; empty, no terminator
25: ; No predecessors!
cleanupret from %cleanuppad unwind label %26
AlwaysInlinerPass walks the empty block and dies. An invoke needs its
unwind edge redirected and the bundle added, not a block of its own -
cloning it in place with CallBase::Create and setUnwindDest, which is
what the funclet-bundle loop a few hundred lines above already does.
The guards were then re-tested one at a time, which is the payoff of
section 9.13 having established that each was individually necessary:
that turns "is this still needed?" into a one-line experiment.
- blockHasNestedUsing is deleted. An outer and an inner using scope now
both dispose on unwind, innermost first. Its cost had been that the
outer one stood down from being wrapped so the inner one could be,
and so never disposed on unwind at all.
- blockIsInsideCatchOrFinally stays. Dropped alone,
`catch (e: int) { using r = new Res(); }` still crashes - a different
cause, still open. Worth naming its second cost while here:
localTakesOwnership consults the same predicate, so a heap local in a
catch or finally clause is not owned and leaks under -mm=rc.
- blockUsingInitializersAreAllNewExpr stays, re-checked, unchanged.
Still open and confirmed independent of this fix: throwing from a
finally still crashes the compiler, in both memory models. That is the
ts.BeginCleanup-with-no-ts.EndCleanup shape section 9.14 describes.
New test: test/tester/tests/00using_nested_scopes.ts, all three models.
It covers a using in an if and in a bare block inside a try body, and
the outer/inner pair both with the inner scope already closed and with
both still live, asserting disposal order rather than just that it
happened.
Full release suite green: 871/871.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5's plan named a verifier from the start - every owned value with a
matching release on every path, unwind paths included - and said to
build it alongside the insertion rather than after. This is that, one
step ahead of the work it exists to guard.
OwnershipVerifierPass runs at the affine level behind --verify-ownership.
Affine because that is the first point where the unwind paths are
ordinary CFG edges and can be walked like any other: before
TryOpLowering they are regions, and after LowerToLLVM the ops are gone.
And in every memory model, not just -mm=rc - ts.RetainSlot and
ts.ReleaseSlot survive to there regardless and are only erased on the
way to LLVM, so a collected build checks the same invariant a counted
one does. That matters more than it sounds: most of the suite, and most
of CI, is collected.
For each ts.RetainSlot it runs a backward must-analysis over the
function's blocks, asking whether any path from the retain to a function
exit passes no release of the same slot. A block counts as releasing if
it does so directly or inside a region of one of its own operations -
conservative on purpose, because a verifier that reports a leak the IR
does pay somewhere the walk does not follow is a verifier that gets
switched off. It checks the direction that leaks rather than the one
that frees live memory, since 5a's insertion is balanced by construction
and what an extension to fields, elements, arguments or returns will get
wrong first is a path out that nobody released on.
A verifier that has never failed might not work, so the unwind-leg
release from section 9.15 was reverse-applied first: it reported the
leak at the right declaration in all three models, and went quiet again
when restored.
On its first run over 460 test files it found two things, both real.
The first is not an RC bug at all. A break or continue written inside
another block skipped every scope between itself and the loop - the
disposals a `using` declared as well as the references those scopes'
locals took. Three iterations of
for (...) { using r = new Res(); if (i == 1) continue; }
disposed twice. The walk outwards stopped at the first scope that was
not itself a loop, and isLoop is set by a loop on the context it hands
its body and then inherited by every context copied from it, so it
answers "somewhere inside a loop", not "is the loop" - the very first
step thought it had already arrived. Written directly in the loop body
it happened to be right, which is why that shape always worked and hid
this one. Fixed by splitting the two meanings: isLoopBodyScope is taken
by the block that becomes the loop's body and cleared for anything
nested further in.
Two plausible-looking attempts either side of it were wrong. Carrying
the target label into the recursion instead of the empty one breaks
02disposable.ts: the loop sites clear `label` before storing it, so a
labelled loop's context holds an empty label too, and `continue cont1`
relies on the outer loop matching the empty one the recursion passes
down. And moving the recursion out of the ownedVars != nullptr guard, on
the reasonable theory that a scope owning nothing says nothing about its
parents, breaks Path.ts - and the isLoop fix made it unnecessary anyway.
The second is left alone and documented: a [Symbol.dispose]() that
itself throws during unwind skips the release that follows it, because
the cleanup region's unwind edge reaches the enclosing catch without
passing it. Fixing that means releasing before disposing, which breaks
the ordering section 9.12 chose deliberately - a disposable is still
usable while its dispose runs, and dropping the last reference first
could have freed it.
New test: test/tester/tests/00break_continue_scope_exit.ts, all three
models. It covers continue and break from inside an if, two levels of
nesting, a `using` in the intermediate scope as well, the labelled form,
and the shape that always worked as a control. Confirmed to fail with
the fix reverse-applied.
Full release suite green: 875/875.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A field store was a bare ts.Store. The runtime half has been there since section 9.4 - releaseFields walks an instance's fields when its release routine runs - but nothing ever took the reference that routine gives up, and overwriting a field dropped the outgoing value without releasing it. isOwnedLocalSlot becomes one arm of isOwningSlot; the other is a ts.PropertyRef whose base is a class or object instance and whose field type owns heap memory. Retain-first, as for locals, which is what makes `h.item = h.item` safe. A field of a record held inline - a tuple in a local, a parameter's slot - is deliberately left out. Its fields are released by whatever owns the record, which is only tracked when that is an owned local, so retaining into a record nothing releases would leak. Arguments and elements ask the same question and deserve one answer rather than three. The new tests have no teeth on counting yet, and that was checked rather than assumed: swapping the store to release-before-retain leaves every case passing, because the birth reference is still unconsumed and every count sits one above the truth. The test header says so. They are written as aliasing cases so they gain teeth the moment the slack goes. This also broke the verifier from the previous commit, instructively: its structural check went from zero findings to 49 files, all false, because an overwrite pairs ts.Retain on the value with ts.ReleaseSlot on the slot, so the slot never appears in a ts.RetainSlot. It now recognises the hand-over by the store that follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The direct sibling of the field slice. A T[] value is { data, length },
and its release routine walks the elements of the data block before
freeing it (buildArrayBody) - the mirror of what releaseFields does for
an instance - so `arr[i] = x` carried the same debt `obj.f = x` did, and
was likewise a bare ts.Store.
A third arm on isOwningSlot: a ts.ElementRef whose base is an ArrayType
and whose element type owns heap memory. Only ArrayType - ts.ElementRef
also addresses a ConstArrayType, whose data is a static literal nothing
releases, and a StringType, whose characters are not references. Element
access already produces ts.Load on a ts.ElementRef, so the store flows
through the same assignment path fields do; the predicate was the whole
change.
push, unshift and splice put a value into that same data block through
their own ops rather than through an assignment, and pop and shift take
one back out. The taking-out half asks the same question a return does,
so those go together in a later slice rather than half here.
These tests do have teeth, unlike the field ones, and that is the
interesting part. The same release-before-retain swap that left every
field case passing makes the element self-assignment read back 0 where
the field one still reads 5. Reduced to two five-line programs, the
asymmetry is not about elements: an array literal stores its elements
without retaining them, so an element seeded by a literal holds only its
birth reference and a release-first drops it straight to zero.
That is a latent over-release in its own right - the array's release
routine gives up a reference the literal never took - cancelled out today,
exactly, by the same unconsumed birth reference. Object literals construct
the same way. So literal construction, not arguments or returns, is the
next slice: it is the one insertion point now shown to be already wrong
rather than merely incomplete, and it has to land before the slack comes
out.
The verifier is unchanged by this step - same two files, same six retain
sites, no new "released but never retained". Section 9.19's hand-over
recognition generalised to elements without modification.
Full release suite green: 883/883.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An array literal's data block and a boxed object literal's storage both
release what they hold when they die, but neither is filled through an
assignment, so neither took a reference to what it captured.
The previous commit predicted this and filed it as latent, to be fixed
before the birth-reference slack came out. Reducing it properly said
otherwise - it frees live memory today:
let kept = new Leaf(7);
let a = [kept];
let b = [kept];
a[0] = new Leaf(1); // kept still 7
b[0] = new Leaf(2); // kept.n now 0, freed while the local holds it
The mechanism recorded in section 9.20 was wrong, and section 9.20 now
says so. The array does not die at scope exit and release an element it
never retained - its data block carries an unconsumed birth reference of
its own, so its release routine never runs at all. What actually happens
is that a literal-seeded element sits at one where an assignment-filled
field sits at two, and each overwrite spends the missing reference: the
first cancelled by the slack, the second going past zero. The slack was
masking exactly one release of it, not the bug.
The fix is one helper used at the two sites that fill an owning block in
one go: the array literal's ts.CreateArray, and the boxed object
literal's ts.New/ts.Store. A record-shaped value retains through its own
routine, which walks its owning fields, so the boxed case takes one
ts.Retain on the whole tuple rather than one per field.
Left open and now bounded: the spread form ([...xs, y]) builds through
ts.ArrayPush and waits for the mutating-ops slice; an unboxed object
literal stays an inline tuple and waits with the inline-record case.
Each test case was checked against the compiler as it stood rather than
the file as a whole: six of the seven return wrong values there, the
seventh being a deliberate control. One overwrite does not bite; one
value reaching two slots that are both overwritten does.
The verifier is unchanged, and that is a limit rather than a clean bill
of health - it pairs ts.RetainSlot with ts.ReleaseSlot, and this bug
lives in the value-form retain against an owning block's eventual
release. The check that would have caught it does not exist yet.
Full release suite green: 887/887.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the insertion points that fill an array's data block. push, unshift and splice put a value in through their own ops rather than through an assignment, so like the literal in the previous commit none of them took a reference to what they inserted, while the block goes on releasing every element it holds when it dies. Same recipe to expose it - one value reaching two slots that are both later overwritten - and the same fix, in MLIRCustomMethods where the three ops are built. This also closes the spread literal left open a commit ago. [...xs, y] is not built by ts.CreateArray at all: mlirGenAppendArrayByEachElement synthesises a for..of calling push, so it inherits push's retain rather than needing one of its own. pop and shift get no counterpart, and that is a decision rather than an omission. The block does not release what it gives up - buildArrayBody loops to size, and both ops shrink size past the slot, so the release routine never reaches it. The reference simply transfers to the returned value, leaving the result with the same "+1 nobody has consumed" every freshly produced value already carries. Pairing a release here would free a value the caller is about to use. The question of what a pop and a return owe each other turns out to be already answered by the existing convention. Still open: what splice deletes is memmoved over and its references dropped without a release. That leaks rather than over-releases, and it cannot be fixed at this level anyway, because the number of elements to release is only known inside the lowering. It is the first item in this arc that will need emission from LowerToLLVM rather than MLIRGen, which also puts it outside what the verifier can see. One test case had to be strengthened, and only running each case separately found it. spreadLiteralSharesValue passed on the unfixed compiler with two overwrites: the source array is itself a literal, so it already holds a legitimate retained reference, and that one extra absorbed the second release. The case was worthless and looked fine. Overwriting the source as well puts the two spread copies back on the hook - 6 where 13 is due. Full release suite green: 891/891. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things were queued for this step - arguments, returns, and the inline-record cases two earlier commits deferred. Checking each before writing anything turned two of the three into no-ops. Arguments are already borrowed, and that is the right convention. A parameter's slot is not marked owned, so passing a heap value neither retains nor releases. The hazard worth testing is a callee that drops every holder of what it was handed, so that case was written - a function passed a value plus the class field, the second holder and the array that all point at it, dropping all three before reading it. It reads correctly, and it has to: every holder that drops also retained when it took. Returns already work too. `return x` releases x's owned slot on the way out, which balances the retain at its declaration, and what the caller receives is the birth reference. That is the same +1 transfer pop and shift perform, arrived at from the other direction. The inline-record case was a live over-release, and the reasoning that deferred it was half wrong. It was excluded because "retaining into a record nothing releases would leak" - but an owned local holding a record does release its fields, since RetainSlot and ReleaseSlot on a record-shaped slot go through the type's own routines and those walk the fields. So the local retained the field's original value and released whatever the field held at scope exit, while an assignment in between swapped that value taking and giving nothing. Two such assignments of one value released it twice and freed it while a local still held it. The rule is conditional, unlike the class one: an inline record's field owns exactly when the storage under it owns, so isOwnedFieldSlot now recurses through a RefType base into isOwningSlot. A parameter's slot answers no, and so does the scratch storage a literal is built in, which is what keeps construction from leaking. Construction needed nothing, and that was checked rather than assumed: a literal is built in scratch storage nobody owns, then copied into the owned local whose RetainSlot retains the fields. That also closes the unboxed object literal left open earlier - never broken, only unexamined. Two of the six test cases had to be reshaped after failing to bite. recordsInsideAnArray cannot bite at all yet, because its releases would come from the array's own release routine and that never runs while the data block carries an unconsumed birth reference; it is kept and labelled as coverage of the predicate's recursion rather than as a counting test. The other needed a third record, because an array holding the same value retains it legitimately and that reference has to be spent first. Full release suite green: 895/895. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_MemoryAlloc now writes zero into the block header instead of one. A block starts unowned; whoever first takes it - a local's declaration, a field or element store, a literal capturing it, a push - is what brings the count to one, and that owner's release is what takes it back to zero and frees it. That is the slack the locals slice deliberately left, and every insertion point that had to exist before it could come out now does. Being born at zero also gives the remaining mistakes a benign shape at the boundary: a release of a block nobody ever took underflows to all-ones, which is HEAP_BLOCK_IMMORTAL, so the block leaks instead of being freed out from under a live reference. The companion change is not optional. The scope exit at a return releases every owned local in the frame, and the returned value is very often held by one of them, so `return x` after `let x = new C()` would free the value on the way out. The value is retained before the scope exit instead. Retaining the value rather than identifying which local holds it is what makes this work for `return h.item`, `return arr[0]` and `return cond ? a : b` alike, and it makes the convention uniform: every function returns +1, the same transfer pop and shift perform. Stated exactly, because half of it is not yet a win. For arrays, strings and boxed object literals this is a real removal of the slack: a data block now goes to one and back to zero and is freed. For class instances it is currently neutral - new C() is a call to a compiler-generated C..new, so the return retain hands back precisely the +1 the birth reference used to provide. Verified rather than assumed, with the release-before-retain swap: all 35 owned and disposable tests still pass, so they have not gained teeth yet. Two comments in the tree were wrong and are corrected here, because both read as authoritative and cost real time before this change could even be designed. Defines.h said the header word "is not yet initialized on allocation - nothing maintains a count", which stopped being true when the count was added; and getHeapBlockHeaderSize claimed class instances bypass the header through GC_malloc_explicitly_typed, a path that sits behind ENABLE_TYPED_GC and was retired long before the header existed. Together they produce a model in which blocks are born at zero and none of the last six slices' arithmetic holds. _MemoryAlloc is the authority. Full release suite green: 895/895. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit left the convention uniform - every function returns +1 - and the leak that came with it: a receiver that retains an already-owned value is one owner above the truth. This closes that for the case that was leaking in every program. A correction to that commit's own list of producers first. It named ts.CreateArray, ts.New, ts.ArrayPop and ts.ArrayShift as +1 alongside calls, which was carried over from the model in which allocations were born at one. They are not: once a block starts unowned, CreateArray and New hand back a value at zero and their receiver's retain is exactly right. Genuinely +1 is narrower - a call that retained on the way out, and pop and shift transferring a reference the data block held. Only new C() is marked, at the one place that builds the call and so knows the callee is the generated C..new. Nothing infers ownership from an operation merely being a call, and that restraint is the whole safety argument: a runtime or builtin helper, or a function imported from a module built before this convention, hands back a heap value with no retain behind it, and consuming one of those would skip a retain nobody performed and free live memory. Answering "not owned" for something that was owned only leaks, so the unknown case falls the safe way. All four receivers consume - a local declaration, a field or element store, a literal capturing a value, and push/unshift/splice. The release side is untouched: what a slot was holding still has to be given up, whoever the incoming reference came from. The declaration case needed the verifier extended, and the first attempt silenced it instead. A consumed local has no ts.RetainSlot; the declaration itself is the acquisition. Adding the slot to the "was it ever retained" set stopped the false reports, but the every-path check iterated the ts.RetainSlot list, so it quietly stopped running for exactly the locals whose release now matters most. The sweep went from two findings to zero, which looked like an improvement and was a regression. The pass now collects acquisitions - a slot paired with the operation to blame - from both shapes. The ownership tests have teeth now. Re-running the release-before-retain swap: before this it failed nothing; now it fails owned-locals, owned-fields and owned-elements. 00owned_fields.ts was written with a header saying it guarded shape and run path but not counting, and asking for this experiment to be re-run once the slack went. It has been, and that header is updated. Still leaking, and now the whole of what is left: every +1 that is not consumed - an ordinary call's result, a discarded pop, a returned value the caller drops. Full release suite green: 895/895. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pop and shift do not release the element they give up - the size shrinks past the slot, so the array's release routine never reaches it again - which hands the data block's reference to whoever receives the result. Marking those two results owned lets a receiver take that reference over instead of adding one, so `let x = arr.pop()` is one owner rather than two. These are the compiler's own operations with known semantics, so there is nothing to classify and nothing to get wrong. The other half of this step does not land, and the reason is worth stating. Every function retains its result on the way out, so `let y = f()` is one owner above the truth as well, and that is the dominant remaining leak. Marking it needs to know that this particular callee retains, and three things stop that being answerable where the call is generated: the retain lives in the return statement, so a concise arrow body and yield reach the return value down paths that do not have it; the callee's FuncOp need not exist when the call site is generated, so a lookup would answer differently depending on declaration order; and a declared, imported or runtime callee looks identical to a local one while having no retaining return at all. That last is the case where being wrong frees live memory. The shape that answers all three is a pass after MLIRGen, when every function is present and its return paths can be inspected rather than predicted. The first version of the new test was worthless and looked fine. It passed with a deliberate over-release injected into pop, because a freed block keeps its contents until something else claims them, so reading through the receiver read the right answer out of freed memory - it caught nothing the existing tests did not already catch. Each case now calls a churn() helper between the transfer and the read, allocating enough same-shaped blocks to land on the freed one, and it then fails against that injection as it should. Injecting the opposite mistake - treating every ts.Load result as already-owned, so receivers stop retaining - fails six of the ownership tests. The suite detects premature frees broadly now, which is the property that matters most from here on. Full release suite green: 899/899. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… in function calls
…nclet Step 6. needsGCRuntime() returned true for `rc` from the start of this arc, so Boehm was still allocating and collecting behind the counts - deliberate while the insertion points were being built, but it meant no memory number taken under `rc` said anything about reference counting. The predicate now names only `gc`. The flip failed one test immediately, and the same file failed under -mm=none on a build with none of this work in it: a `new` inside a Win64 catch clause at -O3. Win32ExceptionPass stamps the funclet bundles correctly; LLVM's DSE then rewrites malloc + memset(0) into calloc without carrying the operand bundles over, so WinEHPrepare emits the handler as a bare prologue with no body and no catchret. Confirmed against stock `opt -O3` and `llc`, and named with -print-after-all. Only `gc` was ever safe, and by accident - GCPass deletes the zero-fill, so the pattern never reached LLVM. A zeroed block is now asked for as `calloc` outright, leaving nothing for that fold to rewrite; GCPass maps it onto GC_malloc (rewritten, not renamed - the arity differs) and drops the unreferenced declaration. The wasm fork has no ts_calloc and no funclets, so it keeps the two-step form, as the intrinsic. Measured, AOT, peak working set: a million-iteration allocation loop holds flat at 3.8 MB under `rc` against 172.8 MB under `none`, the first result in this arc that demonstrates reference counting reclaims anything. raytrace.ts reclaims nothing at all - it is built from `return new Vector(...)` used inline, so every intermediate carries the +1 its return retained with no owner to give it back. That reclassifies item 5l, discarded temporaries, as the dominant leak. New tests: 00alloc_in_catch.ts in all four variants (teeth confirmed with the fix disabled), plus -mm=none variants of 03disposable.ts and 04disposable.ts, which had no non-gc coverage. Found and left alone: a try/catch nested inside a catch crashes with no allocation in it, in every model at every opt level. 909/909 green. Verifier unchanged at two files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… for imported functions - Updated the behavior of the `+` operator to promote both operands to the wider type, aligning it with other arithmetic operators. This change resolves issues where the right operand was coerced to the left operand's type, leading to incorrect results in various scenarios. - Introduced a new `numericPromotionOrder` method to define the order of type promotion for numeric types. - Enhanced the `OwnedReturnConsumptionPass` to correctly classify imported functions that return owned references, allowing for proper memory management across module boundaries. - Added regression tests to ensure the correct behavior of the `+` operator and to verify that imported function results are handled correctly without memory leaks. - Registered new tests for both static and shared builds to cover the changes made in ownership handling and arithmetic operations.
…plice` behavior in reference counting
…ncluding per-model default library builds and linking requirements
…ild.bat and build.sh for different configurations
…builds and update compiler name in output
- Moved the entire Async runtime API implementation from AsyncRuntime.cpp to a new header file AsyncRuntimeCommon.inc. - Updated AsyncRuntime.cpp to include AsyncRuntimeCommon.inc, streamlining the code structure for shared and static builds. - Ensured that the Async runtime API remains functional and maintains its previous behavior after the refactor.
…-library tests The shared collector was only ever built for release, but the tests look for it per configuration under 3rdParty/gcdll/x64/<config>. A debug tree therefore had no gc.lib and all 258 of its -shared tests failed to link rather than running. Built with MultiThreadedDebug to match the debug test linker's libcmtd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…iables
A file whose root held only declarations and variable statements, with no
user-written main(), got no main at all, so `class S {} const s = new S();`
failed with "Symbols not found: [ main ]". Only the entry point was missing -
the initializers already ran from the global constructors.
hasGlobalCode keeps driving the deferral decision unchanged: it is also
processStatements' isRoot argument, and isRoot holds root variables back from
the module level so they can be re-emitted inside the entry function. Counting
variable statements there instead moves them out of the module scope the file's
own functions resolve against, which breaks a root `const` arrow function and a
decorated root `let`. The entry-point decision is now separate.
Both guards are needed. A root that only declares things gets none, because
that is what a library looks like and its object is linked beside a program
with a main of its own. isExecutable is not the test: it is set only by
--emit=exe, while everything that links a program uses --emit=obj and drives
the linker itself, so a DLL is excluded instead.
The test's output comes from a constructor, since any expression statement at
the root would build an entry function for its own sake and stop testing this.
The variables are exported to survive --opt_level=3, which otherwise drops
unused globals and their constructors, side effect and all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ging it llvm::Error's one streaming overload takes const Error& and logs the payload without taking it, and ~Error calls fatalUncheckedError whenever a payload survives. Every error path here therefore aborted with a crash backtrace instead of reporting: a missing entry point printed its message and then "PLEASE submit a bug report". std::move(err) did not help, since it still binds to const Error&. Only visible where LLVM_ENABLE_ABI_BREAKING_CHECKS is on, so debug aborted while release printed one clean line. consumeError rather than toString: both consume, but referencing the out-of-line toString made test-jit-rc-corpus-03union-type double-free deterministically in debug. That code cannot run on a successful lookup, and the AOT sibling of that test already fails on the baseline, so the file has a pre-existing rc bug the JIT variant was passing by luck - left as it is here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…criminant Narrowing was emitted while the case condition was being built, so the block that only compares the discriminant also reinterpreted the union payload as that case's member and, where the member holds references, retained it. A union carries its members in a slot sized for the largest, so when it holds a smaller one everything above it is uninitialized - and under -mm=rc those bytes were walked as string pointers. 03union_type.ts faulted ahead of time for as long as rc has existed. None of that work belonged in the condition to begin with: the discriminant is loaded once before the first case and shared by all of them, so the comparison never read the narrowed value. The only consumer is the case body, which is also the only place the tag is known to match. The regression test is an ordinary well-typed program; the ill-typed literal in 03union_type.ts was never the cause, it only pushed control past the big case. It takes the same route by testing the largest member first, and needs that member to hold references - a bigger member of only numbers gives the reference counting nothing to walk. It faults without this change and has teeth only at -O0, since the optimizer drops the dead extraction at higher levels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Introduced TSLANG_MEMORY_MODEL variable to specify memory models (gc, rc, none). - Updated CMakeLists.txt and related scripts to handle memory model-specific library linking. - Enhanced README documentation to explain memory model usage and configuration. - Modified build scripts to ensure correct library paths for different memory models.
A root that only declares and initializes variables got an entry point whenever the target was not a DLL. Every library root initializes a variable, so a library grew an empty `main` too - visible as a stray `define i32 @main()` under --emit=llvm and --emit=mlir, and fatal for --emit=obj: two library objects linked side by side failed with "lld-link: error: duplicate symbol: main". The emit action cannot answer this. --emit=obj compiles the program and every library linked beside it, and a library root initializing a variable looks exactly like a program root doing the same, so there is nothing in the action to tell them apart. --emit=jit and --emit=exe do say it by themselves; everything else has to be told, which is what --entry-point is for. A DLL never gets one whatever is asked: its root initialization runs from the global constructors and there is no program here to enter. The test runner passes the flag on the single-file object line, and for the first file only of each multi-file and shared build - the first file is the program, the same one --emit=jit runs. Emitting the stub as weak_odr in a comdat was tried instead and rejected: two weak stubs do coexist, but a weak stub still collides with a real strong `main`, so linkage does not get out of the program-vs-library question either. The new test needs both halves to carry a root variable statement and no root code, or the library half stops being a library. Its teeth were checked by passing --entry-point to both halves, which reproduces the duplicate symbol. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s and configuration
…Make configuration
ASDAlexander77
enabled auto-merge (squash)
September 12, 2026 20:24
…platform compatibility
…ries and update EndCleanupOpLowering to reflect unwind behavior
…s and update mlirGen to handle rethrow scenarios in non-Windows environments.
…isable incompatible tests
A program that loads a tslang shared library needs Boehm as a DLL (item 5ao), but the release zip only carried the static gc.lib. The release workflow now builds the shared collector before configuring tslang, so its own -shared tests run, and packages gc.lib (import library) + gc.dll under gcdll/ - a separate folder because both libraries are named gc.lib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ASDAlexander77
force-pushed
the
docs-refcounting-evaluation
branch
from
September 12, 2026 22:03
ecfb4dc to
c478f4c
Compare
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.
Summary
Merges the long-running
docs-refcounting-evaluationbranch (97 commits, 134 files).-mm=rcbeside GC andnone, not a replacement for GC. It covers ownership of fields, elements, literals, array ops, interfaces, closure capture boxes, generators,anyboxing, globals and discarded temporaries. It adds the--verify-ownershippass, and the corpus now runs under every memory model in both tiers. The design notes are indocs/reference-counting-evaluation.md.main(new--entry-pointflag)mainreturned a junk exit codenewarguments was missedTest plan
🤖 Generated with Claude Code