From d3a0191a8a9002fa9ad108e0ec0415869a1aaead Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 19:06:38 +0100 Subject: [PATCH] rc: a global takes the reference of an owned producer through a widening view (#459) `let G: Node | null = mk()` at module level was freed at start under rc (and own). The initializer gives the call's result to `ts.GlobalResult` through a cast to the union; the cast is the call's one use, and a releasable one, so the call's reference was given back at the end of the region as a discarded temporary, and the global pointed at freed memory. A plain `let G = mk()` gives the call to the terminator straight and was fine. claimHandleGlobalInitializers did exactly this claim for Shared handles (own spec 23.1); it is now claimGlobalInitializers and covers every global whose type owns a block: a producer that carries an owned reference, reached through ts.Cast, ts.OptionalValue or ts.CreateUnionInstance, is marked consumed, and the global keeps its count. The retain for a handle read out of a field or element stays handle-only, since under own a retain left in a global's region is an error for any other type. Test: 00global_union_from_call (compile and JIT, and in the rc/none corpus): `Node | null` let and const globals and a `Node | undefined` global from calls, read after allocations of the same size have reused anything freed, then reassigned. It failed under rc before the change. The own corpus is unchanged (453 / 593, 451 with --opt). Fixes #459. Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/OwnedReturnConsumptionPass.cpp | 32 +++++++------- tslang/test/tester/CMakeLists.txt | 3 ++ .../tester/tests/00global_union_from_call.ts | 43 +++++++++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 tslang/test/tester/tests/00global_union_from_call.ts diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index 908fe00b6..a74bd8528 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -123,7 +123,7 @@ class OwnedReturnConsumptionPass // back even when no function here is classified as returning owned. if (returnsOwned.empty()) { - claimHandleGlobalInitializers(module); + claimGlobalInitializers(mth, module); releaseDiscardedTemporaries(mth, module); return; } @@ -236,29 +236,31 @@ class OwnedReturnConsumptionPass consumeConditionalResults(mth, module); - claimHandleGlobalInitializers(module); + claimGlobalInitializers(mth, module); releaseDiscardedTemporaries(mth, module); } - // A handle global (own spec 23.1) is born holding the reference its initializer carries. + // A global is born holding the reference its initializer carries. // // The initializer region ends in `ts.GlobalResult`, a terminator, so a value given to it - // straight is never released as a discarded temporary (§9.30): `let g: Shared = mk()` - // keeps the call's +1. A value given to it through a view of the global's type is: `ts.Cast` - // to `Shared | null`, `ts.OptionalValue` to `Shared | undefined`, `ts.CreateUnionInstance` - // - the view is the one use the producer has, and it is releasable, so the producer's - // reference was given back at the end of the region and the global was born at count 0. + // straight is never released as a discarded temporary (§9.30): `let g = mk()` keeps the call's + // +1. A value given to it through a view of the global's type is: `ts.Cast` to `C | null` or + // `Shared | null`, `ts.OptionalValue` to `C | undefined`, `ts.CreateUnionInstance` - the view + // is the one use the producer has, and it is releasable, so the producer's reference was given + // back at the end of the region and the global was born at count 0, pointing at freed memory + // (#459; own spec 23.1 for handles). // - // The producer is anything that carries an owned reference by now: `new Shared(..)`, marked - // where MLIRGen builds it, or a call, marked above once its callee is classified - which is - // why MLIRGen alone cannot see it. A handle read out of a field or an element carries none, - // and gets a retain of its own. Keyed on the global's type: no other global changes. A read of another - // global is left alone (#446: it reads null today in every model). - void claimHandleGlobalInitializers(mlir::ModuleOp module) + // The producer is anything that carries an owned reference by now: `new Shared(..)` or a fresh + // string, marked where MLIRGen builds it, or a call, marked above once its callee is classified + // - which is why MLIRGen alone cannot see it. A handle read out of a field or an element + // carries none, and gets a retain of its own (handles only: under own a retain left in a + // global's region is an error for any other type). A read of another global is left alone + // (#446: it reads null today in every model). + void claimGlobalInitializers(MLIRTypeHelper &mth, mlir::ModuleOp module) { module.walk([&](mlir_ts::GlobalOp globalOp) { - if (!MLIRTypeHelper::isSharedHandleType(globalOp.getType())) + if (!mth.ownsHeapMemory(globalOp.getLoc(), globalOp.getType())) { return; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2ba3fadfa..0d3a4b378 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -469,6 +469,7 @@ tslang_add_test(NAME test-compile-00-in-method-names COMMAND test-runner "${PROJ tslang_add_test(NAME test-compile-00-class-structural-extends COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_structural_extends.ts") tslang_add_test(NAME test-compile-00-instanceof COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00instanceof.ts") tslang_add_test(NAME test-compile-00-instanceof-union COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00instanceof_union.ts") +tslang_add_test(NAME test-compile-00-global-union-from-call COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_union_from_call.ts") tslang_add_test(NAME test-compile-00-class COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class.ts") tslang_add_test(NAME test-compile-00-class-new COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_new.ts") tslang_add_test(NAME test-compile-00-capture-in-new-arguments COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00capture_in_new_arguments.ts") @@ -952,6 +953,7 @@ tslang_add_test(NAME test-jit-00-in-method-names COMMAND test-runner -jit "${PRO tslang_add_test(NAME test-jit-00-class-structural-extends COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_structural_extends.ts") tslang_add_test(NAME test-jit-00-instanceof COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00instanceof.ts") tslang_add_test(NAME test-jit-00-instanceof-union COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00instanceof_union.ts") +tslang_add_test(NAME test-jit-00-global-union-from-call COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_union_from_call.ts") tslang_add_test(NAME test-jit-00-class COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class.ts") tslang_add_test(NAME test-jit-00-class-new COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_new.ts") tslang_add_test(NAME test-jit-00-capture-in-new-arguments COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00capture_in_new_arguments.ts") @@ -1767,6 +1769,7 @@ set(TSLANG_CORPUS 00instanceof.ts 00instanceof_namespace_narrowing.ts 00instanceof_union.ts + 00global_union_from_call.ts 00interface_captures.ts 00interface_conjunction.ts 00interface_function_typed_field.ts diff --git a/tslang/test/tester/tests/00global_union_from_call.ts b/tslang/test/tester/tests/00global_union_from_call.ts new file mode 100644 index 000000000..813797256 --- /dev/null +++ b/tslang/test/tester/tests/00global_union_from_call.ts @@ -0,0 +1,43 @@ +// A module-level `Node | null` (or `Node | undefined`) initialised from a call keeps the object the +// call made (#459). Under rc the call's reference was released at the end of the initializer, since +// the global's union cast hid it, and the global pointed at freed memory. Each global is read after +// allocations of the same size have reused anything freed. +class Node { + v = 0; + name = ""; +} + +function mk(name: string) { + const n = new Node(); + n.name = name; + return n; +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + n.name = "junk" + i; + keep.push(n); + } + + return keep.length; +} + +let G: Node | null = mk("made" + 1); +const C: Node | null = mk("const" + 2); +let U: Node | undefined = mk("optional" + 3); + +function main() { + assert(churn() == 1000); + assert(G !== null && G.name == "made1", "a union global from a call keeps its object"); + assert(C !== null && C.name == "const2", "a union const from a call keeps its object"); + assert(U !== undefined && U.name == "optional3", "an optional global from a call keeps its object"); + + G = mk("next" + 5); + assert(churn() == 1000); + assert(G !== null && G.name == "next5", "a reassigned union global keeps the new object"); + + print("done."); +}