Repository navigation
-mm=own phase 8: Shared<T> (spec 23) - #463
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andle (spec 23.2, 23.3) Inference never erases, decides or reports a retain or release of a handle (touchesHandle), and lowering counts what is left under own. The signature pass gives handles no owned or borrowed facts; a handle's release, and a store into `s.value`, make a function one that may drop. A borrow read through a handle has a root nobody owns, so any call that may drop ends it, as do `x.value = ...`, a store into a place under any handle, and push/unshift on an array reached through one. The handle's own release or overwrite (a local's ReleaseSlot/Store, a temporary's Release) ends it too; a copy of the handle does not. Also: own_shared_count asserts that a block held by a dying block releases its payload and that `s.value = x` hands the counts over; comment reflow in MLIRGenImpl.h and the GC-root condition wrapped in LowerToLLVM.cpp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (spec 23.3) dropsInBody marked every ReleaseSlot of a slot holding a handle as a drop, ahead of the place rule, so a constructor initialising a Shared<T> | null field lost __own_no_drops and `new C()` ended unrelated borrows in its caller. A handle held by a field or an element is now judged as any place overwrite is; the handle rule applies to ts.Release and to non-place slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cells counted (spec 23.3) - dropsChain: a store into `x.value` ends every borrow read through a handle, whatever the handles' static types (a Shared<Derived> converts to a Shared<Base>, so matching by type compiled a use-after-free). - chainOf: the root of a `s.value` is a handle because of the op, not its type (a view may hide it); merges into it are each handles; rootEnds keys on that. - decideCells: a cell holding a handle is not a borrowing cell; its release gives back the count lowering takes for it (a captured handle parameter leaked one count per call). decideEscaping lets an escaping closure take such a cell. - dropsInBody: push/unshift on an array reached through a handle is a drop (reachesSharedValue, now in OwnershipFacts.h). - Tests: own_err_shared_borrow_other_type, own_err_shared_borrow_push_call, own_err_shared_borrow_reassigned; own_shared_count asserts the counts of a captured and an escaping-captured handle parameter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pec 23.1, 23.4) - A handle's typeof name is "object", so it has a type descriptor; under own the descriptor keeps its retain routine (a handle is counted). - `Shared<T> | null` boxes into `any` under the handle's descriptor (it had no tag at all, and crashed the compiler). - `<Shared<T>>any` goes through ___unbox like a class, checked by the typeof name only. - Shared.count accepts a `Shared<T> | null` or optional narrowed by `if (s)`, which keeps the union's type. - Tests: own_shared_containers and own_shared_cycle under gc, none, rc and own, AOT and JIT, plus --verify-ownership; own_shared_count asserts the narrowed union and the any round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s a handle (spec 23.2) `Shared<T> | number` compiles since the handle has a type descriptor, but own took it for a single-owner value: it erased the union's count and kept the releases, so counts differed from rc's and a loop corrupted the heap. - isSharedHandleType: a union of at least one Shared<T> whose other members own no heap memory (null, undefined, numbers, literals, other Shared<U>) is a handle, so own leaves its retains and releases as rc's. Its routines dispatch on the tag; a member with no retain routine is skipped. - A handle put into a union with an owning member (`Shared<T> | string`, `Shared<T> | Node`) is rejected under own by the inference pass: "a Shared<T> in a union with '<type>', which -mm=own does not count, is not supported". gc, none and rc accept it. - Tests: own_shared_union (all four models, AOT and JIT, verify-ownership) and the negative own_err_shared_union_owned. Spec 23.2 and 23.6 amended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- measure.ps1: a leaf handle in two arrays, flat and equal under own and rc (4.4 MB at 20000 and 200000 iterations), growing under none; a two-node cycle leaks under rc and own exactly as under none - corpus: 453 / 593 plain and 451 / 593 with --opt, the same files and first errors as main - LLVM IR identical to main's for the 451 of 453 files that compile; the other two flip a union's member order between runs of main itself - spec 23.7: the ops as built, the widened handle, tests, negatives, teeth, limits and the pre-existing bugs filed (#453-#456) - the plan's boxes ticked Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GCC in WSL at bbac9ba: builds with no errors, and ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared" passes 213 of 213. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a) runs (spec 23.7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andles, handle arguments, generators (spec 23) - A global handle is born with its count: own's moveIntoGlobals leaves a handle's retain in place, and a `Shared<T> | null` global takes over its initializer's reference through the cast to its type (it was released as a discarded temporary, under rc as well). - A const of a union or optional handle (`const c = p.child`, `const c: Shared<T> | null = a`) gets identity storage, as a bare one does: it is a handle of its own, counted. - A handle read out of a place is a borrow of the place, checked as a field read is, unless rc gives it an owner of its own (a retain, or an owning local's slot): `f(p, p.child)` with `f` overwriting `p.child` is rejected under own. - `s.value` and `Shared.count(s)` are borrows (isBorrow); a generator over a handle parameter owns its box, whose cell holds the handle's count, so it is no longer rejected and gives the count back. - Tests: own_shared_global, own_shared_unlink (all four models, AOT and JIT, verifier), own_err_shared_place_arg; a generator in own_shared_count; graph and union counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ount (spec 23.2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(spec 23.7) The const-handle note corrected, the fix round's tests, teeth, rejections and limits, the corpus and Linux rerun, and what was found and not fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tializer, a handle read with two uses (spec 23) - A handle global takes over its initializer's reference through any view of its type (ts.Cast, ts.OptionalValue, ts.CreateUnionInstance), from `new Shared(..)` or from a call: OwnedReturnConsumptionPass claims it (claimHandleGlobalInitializers) once a call's result is known to carry one, which MLIRGen cannot see. `Shared<T> | undefined = new Shared(..)` and `Shared<T> | null = mk()` were born at count 0 under rc and own. The cast-only claim in MLIRGen goes. - A handle read out of a place is a counted copy only when every use of it is one rc counts (Ruling 7): one use that reads through the place makes it a borrow, tenure-checked, while its retain and the store it feeds stay a counted copy, and that store's release of the old value is not a drop of the read. `f(p, q, q.child = p.child)`, where `f` overwrites both fields, is rejected under own; `Shared.count(q.child = p.child)` stays legal. - Tests: own_shared_global_init and own_shared_place_two_uses (all four models, AOT and JIT, verifier), own_err_shared_place_two_uses, and a generator that outlives the caller's handle in own_shared_count. Docs (spec 23.7): the second fix round in the results - the narrowed global claim (a handle global initialised from another global or a field takes no count; #446 hides it), the per-use exemption, tests and suites (114 / 604 / 1558), teeth, corpus rerun, Linux 255/255, the generator rejection only when it reads the handle, #458, #459 and the not-filed findings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…retains for it, handle globals from a field (spec 23)
A handle read out of a place is exempt from the borrow check only when every
use gives it a counted owner (Ruling 7). Any ts.Store of the read counted as
one, so `{ s: (q.child = p.child) }` - one store rc retains for (q.child),
one it does not (the literal's field) - compiled under own and crashed.
- OwnershipInferencePass: countedStoreRetain finds rc's retain for a store
(Retain(v), [ReleaseSlot(slot)], Store(v, slot)). isCountedHandleReceiver
takes a store only with it, so walkBorrowed skips only such stores, and
collectCountedStoreReleases shares it. handleReadUses marks a store with no
retain as an uncounted use (isUncountedStoreOf), so the read is checked and
the store is its keep.
- OwnedReturnConsumptionPass: a handle global initialised from a field read
(`holder.child`, `new P().child`, `mkP().child`) gets a ts.Retain of its own
right after the read; it was born at count 0 under rc and own. Erased
under gc and none; keyed on the handle type.
- Tests: own_err_shared_place_literal (negative), own_shared_global_field
(positive, gc/none/rc/own, JIT and AOT, verifier under own and rc).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… count too (spec 23) `let g: Shared<T> | null = mkArr()[0]` read an element of a temporary array, which gave its count back at the end of the initializer: the global was born at count 0 under rc and own, as from a field. claimHandleGlobalInitializers retains a handle read out of an element as it does one read out of a field. own_shared_global_field asserts it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store receiver (a store counts only when rc retains for it), the global initialised from a field or an element, and the place-read bullet's misplaced gap; tests, suites (125 / 615 / 1569), four teeth rows, the corpus spot check, the third round's paragraph, Linux 266, and three not-filed findings (a global object literal, a discarded pop, arronly). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nied by default (spec 23, Ruling 9) handleReadUses classified a use as counted, borrowed, or neither; a use that was neither (a tuple's element: `[(q.child = p.child), 1]`) let a read with a counted store stay exempt, and the tuple kept the block with no count of its own (c7, d4, d10 compiled and crashed under own). Every use of a handle read is now classified, and nothing falls through: - counted: a receiver with rc's retain for that very use - a store (countedStoreRetain, now also a return's store into the result slot after the scope exit's releases), push and unshift, `new Shared`, an array literal's element, an `any` box, `ts.ReturnVal` (retainedRightBefore: the row of retains MLIRGen puts right before the op), and an owning local's initializer; - passed on: a cast or view that is still a handle, a merge, a local that owns nothing; - borrowed: everything else - isBorrow's list, a tuple element, an object literal's field, a capture, a yield, a receiver without its retain, a cast to what is not a handle. rc's retains count for nothing alone. A return (result-slot store) and an `any` box are classified before passesOn would hand them on, in handleReadUses and in walkBorrowed's counted-copy skip alike. ReturnInternal is no receiver any more; isUncountedStoreOf is folded into the default. New negative own_err_shared_place_tuple.ts (c7's shape). Spec 23.7: Ruling 9 in the place-read bullet and a fourth-round bullet (with the retain-pairing argument), the new negative, suites 126 / 616 / 1570, two teeth rows, the corpus spot check, Linux 267, and the conservative rejections (a counted read's value in a tuple: c7c, c7d, d3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vative rejection (spec 23.7) 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.
Summary
Phase 8 of
-mm=own(spec §23): a built-in, countedShared<T>for graphs a tree of single owners cannot express (a node in two lists, a DAG, a parent link).Shared<T>is!ts.shared<T>, a handle to a heap block holding the count and oneT. Its ops arets.SharedNew,ts.SharedValueRef(s.valueis ats.Loadof it) andts.SharedCount. Members:new Shared(x),.valueread and write,===/!==,Shared.count(s)(-1under gc and none). A program's ownSharedwins.Shared<T>, an optional of one, or a union ofSharedmembers with members that own no heap (Shared<T> | null | number). Under own, aShared<T>in a union with an owning member (Shared<T> | string) is rejected with a named error.new Shared, an array literal's element, ananybox, or an owning local's slot); a cast or view that is still a handle, a merge or a local that owns nothing passes it on; anything else (a call argument, a tuple's element, an object literal's field, a capture, ayield, any other op) is a borrow.f(p, p.child), withfoverwritingp.child, is rejected under own, and so aref(p, q, q.child = p.child),{ s: (q.child = p.child) }and[(q.child = p.child), 1], where one use is counted and the other is not.cur = cur.value.nextis counted, so walking a graph is not restricted, andShared.count(q.child = p.child)is legal.Shared<T> | null,Shared<T> | undefined, a union), fromnew Shared(..)or from a call, so it is born holding one count under rc and own. A handle read out of a field or an element (holder.child,new P().child,mkArr()[0]) carries no reference, and the global takes a count of its own.any: a handle boxes under its own descriptor (typeof"object"), and unboxes through a checked___unbox.Spec §23.7 records the results. The plan is
docs/superpowers/plans/2026-10-03-own-shared.md.Measured (AOT,
measure.ps1, MB)Corpus and IR
--emit=llvm -mm=own --no-default-libovertest/tester/testsgives 453 / 593 plain and 451 / 593 with--opt. That is the same set of files, with the same first errors, as main.00union_null_undefined_nonstrictand00union_bin_ops2) flip a union's member order between runs of main itself. Each binary gives the same two outputs.-mm=rc, 50 files were compared: 49 are identical, and00union_bin_ops2flips the same way.00union_null_undefined_nonstrictand00funcs(a third flipper of the same kind,___cast<!ts.union<si32, !ts.boolean>,…>, which flips in main too: 11 and 1 over 12 runs) came out in main's other order. Under rc, 49 of 50,00union_bin_ops2again.00union_bin_ops2, main's other output (under rc in one run, under own in the rerun; main gives it 1 run in 10). The 13 that fail fail on both binaries with the same first error.Tests
own_shared_basic,own_shared_user_named,own_shared_graph,own_shared_containers,own_shared_cycle,own_shared_union,own_shared_global,own_shared_unlink,own_shared_global_init,own_shared_place_two_usesandown_shared_global_field. The last five drop the only other owner, reuse its memory, and then read through the handle that is left.own_shared_global_initcovers aShared<T> | undefinedglobal fromnew Shared,letandconstShared<T> | nullglobals from a call, an explicit cast of a call's result, andShared<T> | undefinedandShared<T> | numberfrom a call, each asserting its count of 1.own_shared_global_fieldcovers globals fromholder.child(count 2),new P().child,mkP(..).childandmkArr(..)[0](count 1).own_shared_countruns under rc and own. It asserts every count, including a generator over a handle parameter, handles copied inside a generator, and a generator that reads its handle after the caller has replaced its own and the memory is reused.--verify-ownershipis quiet on the eleven counted files under own and rc.own_err_shared_borrow_write'n' borrows 'a.value' but is used here after it may be released or overwrittenown_err_shared_borrow_callown_err_shared_borrow_push'first' borrows 'a.value.items[]' but is used here after …own_err_shared_borrow_push_call… borrows 'a.value' but is used here after …(push in a callee)own_err_shared_borrow_other_typeShared<Base>aliasown_err_shared_borrow_reassignedown_err_shared_moved_inis used here after its value was movedown_err_shared_union_owneda Shared<Node> in a union with 'string', which -mm=own does not count, is not supportedown_err_shared_place_arg'this value' borrows 'p.child' but is used here after …(f(p, p.child),foverwritingp.child)own_err_shared_place_two_uses'this value' borrows a field but is used here after …(f(p, q, q.child = p.child),foverwriting both fields)own_err_shared_place_literal'this value' borrows a field and cannot be stored, returned or captured({ s: (q.child = p.child) }, then both fields overwritten)own_err_shared_place_tuple'this value' borrows a field and cannot be stored, returned or captured([(q.child = p.child), 1]in aconsttuple, then both fields overwritten)d636eb82; the one failure istest-compile-gc-defaultlib-collector, the known local LNK2005 against the stale installed default library (fails on main the same way).ctest -R ownpasses 616/616, andctest -R "own|rc"passes 1570/1570.ceedafa5as a patch, the build has no errors, andctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"passes 267/267, the same count as on Windows. (Before the fix rounds: 213/213 atbbac9ba8; after the first: 234/234 atfad98c53; after the second: 255/255, its changes applied to476c4d8f; after the third: 266/266 at3a86e20d.)own_shared_countfails. Each fix of the final review's four rounds has its own tooth. The full table is in §23.7.The final review's fix round
The final review found three critical defects and one important one, all fixed here:
moveIntoGlobalserased its retain), and aShared<T> | nullglobal fromnew Sharedgave back its initializer's reference under rc and own. Test:own_shared_global. This round fixed only a cast ofnew Shared; the second round fixed the rest (below).constof a union or optional handle was folded, holding no count and getting no check: unlinking a list node read freed memory. Every handleconstnow has storage. Test:own_shared_unlink.own_err_shared_place_arg.own_shared_count.The second fix round
The re-review found the global fix partial and one new important defect, both fixed here:
new Shared-Shared<T> | undefined = new Shared(..),Shared<T> | null = mk()(letorconst),<Shared<T> | null>mk()- was still born at count 0: own read garbage and crashed, rc printed count 0.OwnedReturnConsumptionPassnow claims the reference through any view, once a call's result is known to carry one (MLIRGen cannot see that). Keyed on the global's type. Test:own_shared_global_init.f(p, q, q.child = p.child), withfoverwriting both fields, compiled under own and read freed memory. The exemption is now per use (Ruling 7): the counted uses are a copy, the other use is tenure-checked, and the counted store's own release of the old value is not a drop of the read. Tests:own_err_shared_place_two_uses,own_shared_place_two_uses.own_shared_count.The third fix round
The second re-review found one more important defect and an understated limit, both fixed here:
const o = { s: (q.child = p.child) }the object literal's field, which rc makes no retain for, kept the read with no count, and own crashed once both fields were replaced. A store now counts only when rc retained for it (ts.Retain, then the store, with or without the old value'sts.ReleaseSlotbetween), and one it did not makes the read a borrow. Test:own_err_shared_place_literal.holder.child,new P().child,mkP().child) was born at count 0 and crashed under rc and own; A global initialized from another global reads garbage (heap-typed globals; crash with a class) #446 hides only the global-from-global case. The global claim now gives a handle read out of a field or an element a retain of its own, right after the read. Keyed on the handle type. Test:own_shared_global_field. The class equivalents read freed memory under rc on main as well and are not changed.The fourth fix round
The third re-review found one more important defect of the same kind, fixed here:
handleReadUseslet a use that was neither a counted receiver nor inisBorrow's list count for nothing either way, so inconst t: [Shared<T>, number] = [(q.child = p.child), 1](and withas const, or aShared<T> | nullelement) the counted store exempted the read, the tuple kept it with no count, and own crashed once both fields were replaced. The default is inverted (Ruling 9): every use is classified, a receiver counts only with rc's retain for that very use (push, unshift,new Sharedand a return now need theirs too, and an array literal's element is now a receiver with its retain), and anything else is a borrow. A return (a store into the result slot after the scope exit's releases) and ananybox are classified before they would be passed on. Test:own_err_shared_place_tuple. Three probes that compiled before are now rejected as conservative (below).Known limits
Every limit in §23.7's "Outside the rule":
Shared<Derived>can be assigned toShared<Base>, which is TypeScript's own unsoundness and is not fixed. Under own, any write through any handle ends every borrow.__own_no_drops, so a call to it ends its caller's borrows;s.value;pa parameter,keep(k, p.child). Copying it into a local first is accepted;f(p, k = p.child), withkan owning local.k = p.child; f(p, k)is accepted;using r = …; return p.child: a scope exit split across blocks hides the return's retain from the check, so it is rejected (it compiled before the fourth round);[(q.child = p.child), 1]with no drop after it, andconst [a, n] = [(q.child = p.child), 1](both compiled before the fourth round).const x = p.child; const t = [x, 1]is accepted;anyunbox checks only thetypeofname, so aShared<B>passes as aShared<A>. A wrong-type unbox cannot be asserted in a test (A let in try whose initializer throws is not caught (stack overflow / uncaught exception) #456).Shared.count(u)andu.valueon an un-narrowedShared<T> | nullread through null.Shared.foo(s), a bareShared) report only "failed statement"; another member gets the generic "Can't resolve property"; a program's ownSharedleaves one dead op behind.MLIRTypeHelperwith a defaultCompileOptions.{ }block does not end a foldedconst boxed: any = h.__owned_resulton the importer's call); classes leak the same way. No test.let o = { s: mkS(4) }) gives the call's reference back and crashes under rc and own; its class equivalent reads freed memory under rc on main.let b: Shared<T> | undefined = new Shared(..)) holds two counts until the end of its block, where the initializer's reference is given back:Shared.countreads one high there. Balanced, not a leak.function f<T>(s: Shared<T>).Issues filed (pre-existing, every model)
const b = aof aletclass value takes no reference.Node | nullcast toanycrashes the compiler.letintrywhose initializer throws is not caught.trythat declares a local crashes.elsewhose body is afor … ofover a generator, inside a loop: "operand does not dominate this use".let G: Node | null = mk()) is freed at initialisation (fixed for handle globals only).{ s: p.child }takes no reference.{ s: mk() }is freed at initialisation.T | null | undefinedinitialised from aT | undefinedcall fails to compile (ts.Globalregion).<Shared<T> | string>ais a compile error in every model; a global initialised by a conditional (let x = on() ? 1 : 2) is a compile error in every model; a discardedarr.pop()does not give back the element it removes (rc and own alike).🤖 Generated with Claude Code