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 #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 diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 33d7aff02..19e138e33 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2447,6 +2447,12 @@ endforeach() # Windows, checked against a library that lists nothing (own-shared-no-drops.cmake). tslang_add_test(NAME test-jit-own-shared-no-drops COMMAND test-runner -jit -shared -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/import_own_no_drops.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/export_own_no_drops.ts") tslang_add_test(NAME test-compile-own-shared-no-drops COMMAND test-runner -shared -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/import_own_no_drops.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/export_own_no_drops.ts") +# An importer builds and uses objects of an own library's classes; under every model, since the pair +# is ordinary TypeScript. +foreach(class_mm own rc none gc) + tslang_add_test(NAME test-jit-${class_mm}-shared-import-class COMMAND test-runner -jit -shared -mm=${class_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/import_own_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/export_own_class.ts") + tslang_add_test(NAME test-compile-${class_mm}-shared-import-class COMMAND test-runner -shared -mm=${class_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/import_own_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/export_own_class.ts") +endforeach() if (WIN32) add_test(NAME test-own-shared-no-drops-listed COMMAND ${CMAKE_COMMAND} diff --git a/tslang/test/tester/own-shared-no-drops.cmake b/tslang/test/tester/own-shared-no-drops.cmake index 1827ce590..bfdaf2d5c 100644 --- a/tslang/test/tester/own-shared-no-drops.cmake +++ b/tslang/test/tester/own-shared-no-drops.cmake @@ -45,6 +45,10 @@ foreach(library_model own rc) "${TSLANG}" --emit=dll -mm=${library_model} --no-default-lib "--llvm-lib-path=${LLVM_LIB}" "--tslang-lib-path=${TSLANG_LIB}" "${SOURCE_DIR}/export_own_no_drops.ts" -o export_own_no_drops.dll) + compile("${dir}" "--emit=dll -mm=${library_model} export_own_class" ok + "${TSLANG}" --emit=dll -mm=${library_model} --no-default-lib + "--llvm-lib-path=${LLVM_LIB}" "--tslang-lib-path=${TSLANG_LIB}" + "${SOURCE_DIR}/export_own_class.ts" -o export_own_class.dll) endforeach() # `import './export_own_no_drops'` finds the DLL in the working directory before the source @@ -57,4 +61,12 @@ compile("${WORK_DIR}/own" "import_own_err_imported_drops against the own library compile("${WORK_DIR}/rc" "import_own_no_drops against the rc library" "${borrow_ended}" "${TSLANG}" --emit=obj -mm=own --no-default-lib "${SOURCE_DIR}/import_own_no_drops.ts" -o import.obj) -message(STATUS "an own library's __own_no_drops reaches its importer, and only what it lists") +# `new H()` of an own library's class is a fresh object this module owns; of an rc library's, whose +# blocks carry a count their module holds, it is not, and a borrow under it ends at any unknown call +compile("${WORK_DIR}/own" "import_own_class against the own library" ok + "${TSLANG}" --emit=obj -mm=own --no-default-lib "${SOURCE_DIR}/import_own_class.ts" -o import.obj) + +compile("${WORK_DIR}/rc" "import_own_class against the rc library" "${borrow_ended}" + "${TSLANG}" --emit=obj -mm=own --no-default-lib "${SOURCE_DIR}/import_own_class.ts" -o import.obj) + +message(STATUS "an own library's __own_no_drops reaches its importer, and only what it lists; its classes can be built") diff --git a/tslang/test/tester/own/export_own_class.ts b/tslang/test/tester/own/export_own_class.ts new file mode 100644 index 000000000..272c770c0 --- /dev/null +++ b/tslang/test/tester/own/export_own_class.ts @@ -0,0 +1,31 @@ +// -mm=own across a module boundary, the library side: classes an importer builds and uses. See +// import_own_class.ts. +export class C { + v: number[] = []; + constructor(public x: number) {} + + twice() { + return this.x * 2; + } +} + +export class H { + c: C = new C(0); + + get cx() { + return this.c.x; + } + + add(n: number) { + this.c.v.push(n); + } +} + +export function sumOf(h: H) { + let s = 0; + for (let i = 0; i < h.c.v.length; i++) { + s += h.c.v[i]; + } + + return s; +} diff --git a/tslang/test/tester/own/import_own_class.ts b/tslang/test/tester/own/import_own_class.ts new file mode 100644 index 000000000..8eaaf09e3 --- /dev/null +++ b/tslang/test/tester/own/import_own_class.ts @@ -0,0 +1,32 @@ +// -mm=own across a module boundary: an importer builds objects of the library's classes. `new H()` +// calls the constructor through a method bound to the new object - `ts.CreateBoundFunction` over +// the object cast to `!ts.opaque` - and that was read as taking the object, so every later use of +// it was "used after its value was moved". It reads the object the way calling a method of this +// module does. Each object is read after a churn that would reuse a block freed too early. +import './export_own_class' + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new C(999)); + } +} + +function main() { + let t = 0; + for (let i = 0; i < 1000; i++) { + const h = new H(); + h.c = new C(i % 10); + h.add(1); + h.add(2); + churn(); + // a virtual call on an imported class may be to an override that drops anything under + // `h`, so `h.cx` comes before the borrow of `h.c`; `sumOf` is listed as dropping nothing + t += h.cx; + const c = h.c; + t += c.x + c.twice() + sumOf(h) + c.v.length; + } + + assert(t == 45 * 100 * 4 + 1000 * 5); + print("done."); +} diff --git a/tslang/test/tester/test-runner.cpp b/tslang/test/tester/test-runner.cpp index 73344f274..d99c257d6 100644 --- a/tslang/test/tester/test-runner.cpp +++ b/tslang/test/tester/test-runner.cpp @@ -791,8 +791,11 @@ void readParams(int argc, char **argv, std::vector &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])) { 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);