From c542bce3df877edb34e65fc5010fe386e194d5d8 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 23:10:33 +0100 Subject: [PATCH 1/3] -mm=own: an importer can build objects of an imported class `new H()` of a class from a library calls the library's H..new and then H.constructor through a method bound to the new object: CreateBoundFunction(Cast(h to opaque), ctor), split straight back into GetThis and GetMethod. The inference read the cast as taking h, so every later use was "used after its value was moved". The cast is now a borrow when that is its only use and the object comes out again only as a call's argument - what ThisSymbolRef is for a method of this module. The object is also fresh when the library was built under own: its ..new is listed among the library's __own_no_drops, and the signature pass marks such a call __own_fresh_result, which isFresh reads. A library built under rc lists nothing, and its blocks carry a count its own module holds, so there a borrow under the object still ends at any unknown call. test-runner: in -shared mode -mm= reached only the library, and every `-shared -mm=...` test built its program under gc. It now goes into tslang_opt, like -x86's triple; the 302 -shared tests pass with it. Tests: import_own_class.ts + export_own_class.ts as a -shared pair under own, rc, none and gc, AOT and JIT; own-shared-no-drops.cmake checks the program against the same library built under rc is rejected. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 27 ++++++++++++-- tslang/lib/TypeScript/OwnershipFacts.h | 15 ++++++-- .../lib/TypeScript/OwnershipInferencePass.cpp | 36 +++++++++++++++++-- .../lib/TypeScript/OwnershipSignaturePass.cpp | 11 ++++++ tslang/test/tester/CMakeLists.txt | 6 ++++ tslang/test/tester/own-shared-no-drops.cmake | 14 +++++++- tslang/test/tester/own/export_own_class.ts | 31 ++++++++++++++++ tslang/test/tester/own/import_own_class.ts | 32 +++++++++++++++++ tslang/test/tester/test-runner.cpp | 7 ++-- 9 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 tslang/test/tester/own/export_own_class.ts create mode 100644 tslang/test/tester/own/import_own_class.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 4f3d379e7..8f05f5789 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -964,13 +964,36 @@ borrow of a parameter's field across calls to `total` and `M.first`. On Windows, rejected, because `shrink` removes an element and is not listed. And the same program is rejected against the library built under rc, which lists nothing: that is the fact's teeth. +Until the fix below, test-runner's `-shared` mode passed `-mm=` to the library only, and the +program was built under gc. Every `-shared -mm=rc`, `-mm=none` and `-mm=own` test ran a mixed +link. So, before it, the two runner tests above showed only that the pair works; the Windows +script was the one checking the fact. The flag now goes to every file, and all 302 `-shared` +tests pass with it. + +**Objects of an imported class.** `new H()` of a class from a library builds its object with the +library's `H..new` and runs `H.constructor` through a method bound to it: +`ts.CreateBoundFunction(ts.Cast(h to !ts.opaque), ctor)`, split back into `ts.GetThis` and +`ts.GetMethod` for the call. The cast read as taking `h`, so every later use of `h` was "used after +its value was moved". The cast is now a borrow when it has only that use, and when the object comes +out again only as a call's argument, as `ts.ThisSymbolRef` is for a method of this module. + +The object is also fresh, and owned here, when the library was built under own. Its `..new` is +listed among the library's `__own_no_drops`, and the signature pass marks such a call +`__own_fresh_result`, which `isFresh` reads. Nothing else makes an imported call's result fresh. +A library built under rc lists nothing, and its blocks carry a count their own module holds. There, +a borrow under the object still ends at any unknown call. `import_own_class.ts` and +`export_own_class.ts` run as a `-shared` pair under every model, AOT and JIT. The Windows script +checks that the program is rejected against the rc library. + ### 15.7 Known limits (the input to phase 5 and later) - An exported function, or a method of an exported class, gets no owned or borrowed facts (ยง15.8 says why they cannot be exported), and a virtual call on one gets no `__own_no_drops`. That is sound and restrictive for `-shared` modules. -- An importer under own cannot yet build an object of an imported class: `new H()` through the - library reports `'this value' is used here after its value was moved`. +- A value an importer under own receives from a library built under rc (a call's result, now an + object it builds with `new` too) is destroyed at the end of its owner's scope. Own's release + skips only immortal blocks. So if the library kept a reference of its own, that is a + use-after-free, where the mixed-link policy promises a leak. - A parameter kept on some paths only (needs drop elaboration: a release on the others). - A borrowed result's places are a wildcard, so any field overwrite between the call and the use drops it, even of an unrelated object. diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 6465c42e8..eb98a3e44 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -34,6 +34,11 @@ namespace mlir_ts = mlir::typescript; // On a function whose body relies on a fact its callers cannot all know (a parameter it keeps, a // result that borrows): why, for the error the body gets instead. #define OWN_FACTS_LOST_ATTR_NAME "__own_facts_lost" +// A call of another module's `..new`, from a library built under own: it allocates the +// object and hands it over, so the result is fresh (isFresh). Only such a library says so - it lists +// the function among its `__own_no_drops` - and only its blocks are ones this module may destroy: +// a block made under rc still carries a count its own module holds. +#define OWN_FRESH_RESULT_ATTR_NAME "__own_fresh_result" // The arguments of a call, without the callee value of an indirect one. inline mlir::OperandRange callArgs(mlir::Operation *op) @@ -166,8 +171,9 @@ inline bool isLiteral(mlir::Operation *def) // Made here and held by nobody else: an allocation, a literal cast to its value type, an // operation rc marks as arriving with a reference, or a direct call of a function this module // defines (every function returns its result retained, rc 9.24; the call's own mark does not -// survive the affine lowering). A declared callee, an indirect call, a parameter, a load, a -// value merged from branches, or a result that borrows an argument is not fresh. +// survive the affine lowering), or another own module's `..new` (OWN_FRESH_RESULT_ATTR_NAME). A +// declared callee, an indirect call, a parameter, a load, a value merged from branches, or a result +// that borrows an argument is not fresh. inline bool isFresh(mlir::Value value) { auto *def = value.getDefiningOp(); @@ -188,6 +194,11 @@ inline bool isFresh(mlir::Value value) return callee && !callee.isDeclaration(); } + if (def->hasAttr(OWN_FRESH_RESULT_ATTR_NAME)) + { + return true; + } + if (mlir::isa(def)) { return false; diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 47ad7bb1d..69d756c3c 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -264,7 +264,7 @@ class OwnershipInferencePass : public mlir::PassWrapperremoveAttr(name); } @@ -1466,7 +1466,8 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) { - return mlir::isa(castOp.getType()) || castOp.getType().isInteger(1); + return mlir::isa(castOp.getType()) || castOp.getType().isInteger(1) || + isBoundForCall(castOp); } // arguments are borrowed, but one the callee keeps (`__own_params`) is taken; a callee @@ -1501,6 +1502,37 @@ class OwnershipInferencePass : public mlir::PassWrapper(castOp.getType()) || castOp->use_empty()) + { + return false; + } + + return llvm::all_of(castOp->getUses(), [](mlir::OpOperand &use) { + auto boundOp = mlir::dyn_cast(use.getOwner()); + if (!boundOp || boundOp.getThisVal() != use.get()) + { + return false; + } + + return llvm::all_of(boundOp->getUsers(), [](mlir::Operation *boundUser) { + if (mlir::isa(boundUser)) + { + return true; + } + + return mlir::isa(boundUser) && + llvm::all_of(boundUser->getUsers(), [](mlir::Operation *thisUser) { return isCall(thisUser); }); + }); + }); + } + // Is `value` an argument the call's callee keeps? static bool isKeptArgument(mlir::Operation *call, mlir::Value value) { diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index c97af5ff5..bf70a1e34 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -94,6 +94,13 @@ class OwnershipSignaturePass : public mlir::PassWrappersetAttr(OWN_NO_DROPS_ATTR_NAME, mlir::UnitAttr::get(&getContext())); } + + // `new C()` of a class from a library built under own: see OWN_FRESH_RESULT_ATTR_NAME + if (call.callees.imported && call.callees.importedName.ends_with("." NEW_METHOD_NAME) && + call.op->getNumResults() == 1) + { + call.op->setAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + } } exportNoDrops(); @@ -108,6 +115,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper funcs; }; @@ -291,6 +300,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper &files) std::string(argv[index]) == "-mm=none" || std::string(argv[index]) == "-mm=own") { memoryModel = std::string(argv[index]).substr(4); - tslang_opt_ext += " "; - tslang_opt_ext += argv[index]; + // into tslang_opt, like -x86's triple: every file of a multi-file test is built under + // the model, the program as well as its library. In tslang_opt_ext it reached only + // the library, and every `-shared -mm=...` test built its program under gc. + tslang_opt += " "; + tslang_opt += argv[index]; } else if (exists(argv[index])) { From 3d38965f357556dbfe7030cc0a854db73b7718b1 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 23:44:57 +0100 Subject: [PATCH 2/3] JIT: malloc and free are the process heap's, as every module's are CI failed test-jit-rc-shared-import-class and test-jit-own-shared-import-class with 0xC0000005. An object of a library's class under rc or own is made in the library and destroyed by the program, and the JIT bound the program's malloc and free to tslang.exe's own. With the prebuilt LLVM CI uses, those are rpmalloc's (LLVMSupport brings it as the process malloc, see scripts/llvm_prebuilt_common.ps1), while the library - like every module tslang builds, and TypeScriptRuntime.dll - allocates with the static release CRT, on the process heap. A block of one freed by the other faults. gc and none never free across the boundary, and a locally built LLVM has no rpmalloc, so only CI saw it. In a release build the JIT now takes malloc, calloc, realloc and free from ucrtbase.dll, the release CRT on the process heap. A debug build keeps its own: a debug CRT puts a header in front of every block, and the libraries it builds link the debug CRT too. Checked by binding the JIT to a private heap: exactly the CI pattern (own and rc fail, none and gc pass); with the fix all four pass. Co-Authored-By: Claude Opus 5.5 --- tslang/tslang/jit.cpp | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tslang/tslang/jit.cpp b/tslang/tslang/jit.cpp index b96263707..9493e3184 100644 --- a/tslang/tslang/jit.cpp +++ b/tslang/tslang/jit.cpp @@ -348,6 +348,30 @@ static void jitAssertFailed(const char *message, const char *file, unsigned line _exit(3); } +#ifdef _WIN32 +// The allocator JIT'd code gets, as `malloc`, `calloc`, `realloc` and `free`. A block it allocates +// can be freed by another module and the other way round - an object of a library's class under +// -mm=rc or -mm=own is made in the library and destroyed by the program - so it has to be the +// allocator those modules use. Every one of them links the static release CRT, whose heap is the +// process heap: a library tslang builds, TypeScriptRuntime.dll. tslang.exe's own `malloc` need not +// be: the prebuilt LLVM brings rpmalloc as the process malloc of whatever links LLVMSupport (see +// scripts/llvm_prebuilt_common.ps1), and a block of one freed by the other faults. ucrtbase.dll's +// are the release CRT's, on the process heap. +// +// Not in a debug build: a debug CRT puts a header of its own in front of every block, and the +// libraries a debug tslang builds link the debug CRT too. +static void *jitHeapFunction(const char *name, void *ownFunction) +{ +#ifdef _DEBUG + return ownFunction; +#else + static auto ucrt = LoadLibraryW(L"ucrtbase.dll"); + auto function = ucrt ? reinterpret_cast(GetProcAddress(ucrt, name)) : nullptr; + return function ? function : ownFunction; +#endif +} +#endif + #ifndef _WIN32 // glibc's `__assert_fail`, which the lowering calls off Windows, ends in abort(). tslang's own // crash handler takes that SIGABRT for a compiler crash and prints a "Stack dump" after the @@ -675,10 +699,10 @@ static int prepareJitProcess(CompileOptions &compileOptions) }; addSym("puts", (void*)&puts); addSym("printf", (void*)&printf); - addSym("malloc", (void*)&malloc); - addSym("free", (void*)&free); - addSym("realloc", (void*)&realloc); - addSym("calloc", (void*)&calloc); + addSym("malloc", jitHeapFunction("malloc", (void *)&malloc)); + addSym("free", jitHeapFunction("free", (void *)&free)); + addSym("realloc", jitHeapFunction("realloc", (void *)&realloc)); + addSym("calloc", jitHeapFunction("calloc", (void *)&calloc)); addSym("memset", (void*)&memset); addSym("memcpy", (void*)&memcpy); addSym("fflush", (void*)&fflush); @@ -835,10 +859,10 @@ static std::unique_ptr createJit(llvm::orc::JITTargetMachineBu }; addOverride("puts", (void *)&puts); addOverride("printf", (void *)&printf); - addOverride("malloc", (void *)&malloc); - addOverride("free", (void *)&free); - addOverride("realloc", (void *)&realloc); - addOverride("calloc", (void *)&calloc); + addOverride("malloc", jitHeapFunction("malloc", (void *)&malloc)); + addOverride("free", jitHeapFunction("free", (void *)&free)); + addOverride("realloc", jitHeapFunction("realloc", (void *)&realloc)); + addOverride("calloc", jitHeapFunction("calloc", (void *)&calloc)); addOverride("memset", (void *)&memset); addOverride("memcpy", (void *)&memcpy); addOverride("fflush", (void *)&fflush); From b504330d8b77e43209b1362de02d39ab771b0d37 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 00:08:59 +0100 Subject: [PATCH 3/3] Runtime: the memory it hands the JIT is on the same heap the JIT frees to After the previous commit, CI failed test-jit-rc-corpus-00async-gc-threading and test-jit-none-corpus-00async-gc-threading with 0xC0000374. The coroutine lowering takes a frame from `aligned_alloc` - TypeScriptRuntime.dll's shim, which called that DLL's `malloc` - and gives it back with plain `free`, which the JIT now binds to the process heap. TypeScriptRuntime.dll links LLVMSupport (add_mlir_library), so against the prebuilt LLVM its `malloc` is rpmalloc too. Before, both sides happened to be rpmalloc. Alloc, AlignedAlloc, Free and AlignedFree - the runtime's only exports that hand out memory - now take malloc and free from ucrtbase.dll in a release build, as jit.cpp does; a debug build keeps its own. Checked by putting those four on a private heap of their own, CI's condition: exactly those two tests fail; with the fix both pass. Windows 3253/3253. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScriptRuntime/MemRuntime.cpp | 45 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/tslang/lib/TypeScriptRuntime/MemRuntime.cpp b/tslang/lib/TypeScriptRuntime/MemRuntime.cpp index c7543b977..3017ea585 100644 --- a/tslang/lib/TypeScriptRuntime/MemRuntime.cpp +++ b/tslang/lib/TypeScriptRuntime/MemRuntime.cpp @@ -15,6 +15,10 @@ #include #else #include "malloc.h" +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include #endif // _WIN32 #include @@ -32,14 +36,45 @@ namespace mlir namespace runtime { -extern "C" void *Alloc(uint64_t size) { return malloc(size); } +// The heap these hand out: the release CRT's, on the process heap, which is also what JIT-compiled +// code gets as `malloc` and `free` (tslang/jit.cpp, jitHeapFunction) - a coroutine frame this +// allocates, the program frees with plain `free`. This DLL's own `malloc` need not be that heap: +// linked against the prebuilt LLVM, LLVMSupport makes it rpmalloc (see +// scripts/llvm_prebuilt_common.ps1), and a block of one freed by the other corrupts the heap. A +// debug build keeps its own, as the JIT does: the debug CRT puts a header in front of each block. +#if defined(_WIN32) && !defined(_DEBUG) +template static F crtFunction(const char *name, F ownFunction) +{ + static auto ucrt = LoadLibraryW(L"ucrtbase.dll"); + auto function = ucrt ? reinterpret_cast(GetProcAddress(ucrt, name)) : nullptr; + return function ? function : ownFunction; +} + +static void *heapMalloc(size_t size) +{ + static auto function = crtFunction("malloc", &malloc); + return function(size); +} + +static void heapFree(void *ptr) +{ + static auto function = crtFunction("free", &free); + function(ptr); +} +#else +static void *heapMalloc(size_t size) { return malloc(size); } + +static void heapFree(void *ptr) { free(ptr); } +#endif + +extern "C" void *Alloc(uint64_t size) { return heapMalloc(size); } // What MSVC's `malloc` guarantees: enough for any fundamental type, 16 bytes on x64. static constexpr uint64_t kMallocAlignment = 2 * sizeof(void *); extern "C" void *AlignedAlloc(uint64_t alignment, uint64_t size) { #ifdef _WIN32 - // Everything here comes from `malloc`, so the block can go back through either `free` or + // Everything here comes from `heapMalloc`, so the block can go back through either `free` or // AlignedFree and both are right. `malloc`'s own guarantee covers every request anything // makes - the coroutine frame asks for 8. A stricter request cannot be served and stay // `free`-compatible at the same time, and silently handing back under-aligned memory is the @@ -50,7 +85,7 @@ extern "C" void *AlignedAlloc(uint64_t alignment, uint64_t size) { alignment, kMallocAlignment); } - return malloc(size); + return heapMalloc(size); #else void *result = nullptr; (void)::posix_memalign(&result, alignment, size); @@ -58,9 +93,9 @@ extern "C" void *AlignedAlloc(uint64_t alignment, uint64_t size) { #endif } -extern "C" void Free(void *ptr) { free(ptr); } +extern "C" void Free(void *ptr) { heapFree(ptr); } -extern "C" void AlignedFree(void *ptr) { free(ptr); } +extern "C" void AlignedFree(void *ptr) { heapFree(ptr); } } // namespace runtime } // namespace mlir