diff --git a/.commit-msg-ownership.txt b/.commit-msg-ownership.txt new file mode 100644 index 000000000..8c003a420 --- /dev/null +++ b/.commit-msg-ownership.txt @@ -0,0 +1,29 @@ +Let a cleanup keep its releases when a disposal throws + +A scope's cleanup region runs while an exception is already unwinding, and +TryOpLowering marks every call in a scope's body with that scope's landing +pad - which reaches into the cleanup regions of the scopes nested inside it. +The inner cleanup's [Symbol.dispose]() therefore became an invoke unwinding +to the outer cleanup, and the ts.ReleaseSlot written after it was stepped +over: the reference that scope took was never given back. This is what the +ownership verifier had been reporting on the two nested `using` scopes of +00break_continue_scope_exit.ts. + +The fix removes the edge rather than adding a release. A function's outermost +cleanup has always used a plain call, because nothing encloses it and there +was no landing pad to mark it with; skipping calls written inside a nested +cleanup region makes every cleanup agree with the one that was already right. +The price is the C++ rule - a disposal that throws while unwinding terminates +instead of continuing outwards - and only in principle, since a throwing +[Symbol.dispose]() fails to JIT today in every model, un-nested included. + +Wrapping the cleanup's disposals in a TryOp of their own was tried first and +failed 24 tests: a TryOp inside a TryOp is the construct section 9.11 already +records as broken. + +The verifier now runs in ctest as well. Both of its real findings came from a +hand-run sweep that nothing repeated, which is why this pair stayed open; +verify-ownership.cmake is that sweep over every corpus file, in eight shards +of about four seconds. Suite 2,641 -> 2,649, all green. + +Co-Authored-By: Claude Opus 5 diff --git a/.github/workflows/cmake-test-release-win.yml b/.github/workflows/cmake-test-release-win.yml index f95024734..a7b488698 100644 --- a/.github/workflows/cmake-test-release-win.yml +++ b/.github/workflows/cmake-test-release-win.yml @@ -125,6 +125,27 @@ jobs: run: dir shell: pwsh + # Boehm as a DLL, for the -shared tests only: an executable and a shared library that each + # link gc.lib statically get two collectors (item 5ao). Mirrors + # scripts/build_gc_release_shared_vs.bat; must be installed before tslang is configured, + # since the test CMakeLists checks for it at configure time. + - name: Configure GC (shared) + continue-on-error: false + run: New-Item -ItemType Directory -Force -Path ".\__build\gcdll\msbuild\x64\release" | Out-Null; cd ".\__build\gcdll\msbuild\x64\release"; cmake ../../../../../3rdParty/gc-${{ env.GC_VERSION }} -G "Visual Studio 18 2026" -A x64 -Wno-dev -DCMAKE_INSTALL_PREFIX=${{github.workspace}}/3rdParty/gcdll/x64/release -DBUILD_SHARED_LIBS=ON -Denable_threads=ON -Denable_cplusplus=OFF -Denable_docs=OFF -DCMAKE_POLICY_DEFAULT_CMP0091=NEW -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded + shell: pwsh + + - name: Build GC (shared) + continue-on-error: false + working-directory: ${{github.workspace}}/__build/gcdll/msbuild/x64/release + run: cmake --build . --config ${{ env.BUILD_TYPE }} -j 8 + shell: pwsh + + - name: Install GC (shared) + continue-on-error: false + working-directory: ${{github.workspace}}/__build/gcdll/msbuild/x64/release + run: cmake --install . --config ${{ env.BUILD_TYPE }}; dir ${{github.workspace}}/3rdParty/gcdll/x64/release/lib, ${{github.workspace}}/3rdParty/gcdll/x64/release/bin + shell: pwsh + - name: Configure continue-on-error: false working-directory: ${{github.workspace}}/__build/tslang/msbuild/x64/release diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 94ffebab2..7d8f0db30 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -122,6 +122,28 @@ jobs: run: cmake --install . --config ${{ env.BUILD_TYPE }} shell: pwsh + # Boehm as a DLL: an executable and a tslang shared library that each link the static + # gc.lib get two collectors, and one frees what the other still holds (item 5ao). Used by + # the -shared tests, and shipped in the zip's gcdll folder. Mirrors + # scripts/build_gc_release_shared_vs.bat; installed before tslang is configured, since the + # test CMakeLists checks for it at configure time. + - name: Configure GC (shared) + continue-on-error: false + run: New-Item -ItemType Directory -Force -Path ".\__build\gcdll\msbuild\x64\release" | Out-Null; cd ".\__build\gcdll\msbuild\x64\release"; cmake ../../../../../3rdParty/gc-${{ env.GC_VERSION }} -G "Visual Studio 18 2026" -A x64 -Wno-dev -DCMAKE_INSTALL_PREFIX=${{github.workspace}}/3rdParty/gcdll/x64/release -DBUILD_SHARED_LIBS=ON -Denable_threads=ON -Denable_cplusplus=OFF -Denable_docs=OFF -DCMAKE_POLICY_DEFAULT_CMP0091=NEW -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded + shell: pwsh + + - name: Build GC (shared) + continue-on-error: false + working-directory: ${{github.workspace}}/__build/gcdll/msbuild/x64/release + run: cmake --build . --config ${{ env.BUILD_TYPE }} -j 8 + shell: pwsh + + - name: Install GC (shared) + continue-on-error: false + working-directory: ${{github.workspace}}/__build/gcdll/msbuild/x64/release + run: cmake --install . --config ${{ env.BUILD_TYPE }} + shell: pwsh + - name: Configure continue-on-error: false working-directory: ${{github.workspace}}/__build/tslang/msbuild/x64/release @@ -178,10 +200,16 @@ jobs: - name: Create Zip of Windows Asset working-directory: ${{github.workspace}}/__build # Flatten the compiler/runtime binaries into the archive root, and add the - # default library as a whole tree so its per-build subfolders are preserved: - # defaultlib/dll/{debug,release}, defaultlib/lib/{debug,release}, + # default library as a whole tree so its per-build, per-memory-model subfolders + # are preserved: + # defaultlib/{dll,lib}/{debug,release}/{gc,rc,none}, # defaultlib/*.d.ts, defaultlib/generics/ - run: Get-ChildItem -Path .\tslang\msbuild\x64\release\bin\tslang.exe, .\tslang\msbuild\x64\release\bin\TypeScriptRuntime.dll, .\gc\msbuild\x64\release\${{ env.BUILD_TYPE }}\gc.lib, .\tslang\msbuild\x64\release\lib\TypeScriptAsyncRuntime.lib, ..\3rdParty\llvm\x64\release\lib\LLVMSupport.lib, ..\3rdParty\llvm\x64\release\bin\wasm-ld.exe, ..\TypeScriptCompilerDefaultLib\__build | Compress-Archive -DestinationPath ..\tslang.zip + # plus the shared collector in its own folder, because its import library is also named + # gc.lib: gcdll/gc.lib + gcdll/gc.dll, for programs that load a tslang shared library. + run: | + New-Item -ItemType Directory -Force -Path .\gcdll_stage\gcdll | Out-Null + Copy-Item -Path ..\3rdParty\gcdll\x64\release\lib\gc.lib, ..\3rdParty\gcdll\x64\release\bin\gc.dll -Destination .\gcdll_stage\gcdll -ErrorAction Stop + Get-ChildItem -Path .\tslang\msbuild\x64\release\bin\tslang.exe, .\tslang\msbuild\x64\release\bin\TypeScriptRuntime.dll, .\gc\msbuild\x64\release\${{ env.BUILD_TYPE }}\gc.lib, .\tslang\msbuild\x64\release\lib\TypeScriptAsyncRuntime.lib, ..\3rdParty\llvm\x64\release\lib\LLVMSupport.lib, ..\3rdParty\llvm\x64\release\bin\wasm-ld.exe, ..\TypeScriptCompilerDefaultLib\__build, .\gcdll_stage | Compress-Archive -DestinationPath ..\tslang.zip shell: pwsh - name: Archive Zip of Windows Asset @@ -375,8 +403,8 @@ jobs: cp ../3rdParty/llvm/release/bin/wasm-ld ./__stage/ cp ../3rdParty/llvm/release/lib/libLLVMSupport.a ./__stage/ cp ../3rdParty/llvm/release/lib/libLLVMDemangle.a ./__stage/ - # Add the default library as a whole tree so its per-build subfolders are - # preserved: defaultlib/dll/{debug,release}, defaultlib/lib/{debug,release}, + # Add the default library as a whole tree so its per-build, per-memory-model + # subfolders are preserved: defaultlib/{dll,lib}/{debug,release}/{gc,rc,none}, # defaultlib/*.d.ts, defaultlib/generics/ cp -r ../TypeScriptCompilerDefaultLib/__build/defaultlib ./__stage/ tar -czvhf ../tslang.tar.gz -C ./__stage . diff --git a/.vscode/settings.json b/.vscode/settings.json index b4d8c3583..be8638a54 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,8 @@ { - "C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools" -} \ No newline at end of file + "C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools", + // Release builds of the MLIR-heavy TUs peak at ~3 GB RSS per cc1plus. + // Ninja's default (nproc + 2 = 26 here) exhausts RAM and the OOM killer + // takes out cc1plus ("g++: fatal error: Killed signal terminated program + // cc1plus"). 8 matches build_tslang_release.sh. + "cmake.parallelJobs": 8 +} diff --git a/README.md b/README.md index c1f3ae14a..99a6ac332 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ on the fly via a built-in JIT — no Node.js or JavaScript runtime required. - [Debugging JIT code with GDB (Linux)](#debugging-jit-code-with-gdb-linux) - [As a native executable](#compile-as-binary-executable) - [As WebAssembly](#compiling-as-wasm) +- [Memory models](#memory-models) - [Building from source](#build) - [Community](#chat-room) - [License](#license) @@ -326,7 +327,7 @@ Hello World! Build ```bat -tslang.exe --emit=exe --nogc -mtriple=wasm32-unknown-unknown hello.ts +tslang.exe --emit=exe -mm=none -mtriple=wasm32-unknown-unknown hello.ts ``` Run ``run.html`` @@ -474,6 +475,24 @@ Run ``run.html`` +## Memory models + +How heap memory is managed is selected with `-mm=`: + +| flag | | | +| --- | --- | --- | +| `-mm=gc` | garbage collection (Boehm) | the default | +| `-mm=rc` | reference counting - freed as soon as the last reference goes, no collector, no `libgc` | **does not collect reference cycles** | +| `-mm=none` | nothing is ever freed | short-lived programs | + +`-mm=gc` is the default and needs no thought. `-mm=rc` reclaims memory deterministically and +holds close to the working set - a ray tracer that reaches 114 MB under `-mm=none` holds 4.1 MB, +against garbage collection's 5.8 - but **objects that refer to each other in a cycle are never +freed under it**, which is the same trade Swift makes with ARC. + +See **[docs/memory-models.md](docs/memory-models.md)** for which shapes leak, which do not, and +what to do about it. + ## Build ### Build on Windows diff --git a/docs/VisualStudio/CustomTool/tslang.xml b/docs/VisualStudio/CustomTool/tslang.xml index 221e08d8b..b7d3a657a 100644 --- a/docs/VisualStudio/CustomTool/tslang.xml +++ b/docs/VisualStudio/CustomTool/tslang.xml @@ -31,7 +31,7 @@ - + diff --git a/docs/compiler-explorer/lib/compilers/typescript.js b/docs/compiler-explorer/lib/compilers/typescript.js index 155683202..1c8279961 100644 --- a/docs/compiler-explorer/lib/compilers/typescript.js +++ b/docs/compiler-explorer/lib/compilers/typescript.js @@ -36,7 +36,7 @@ export class TypeScriptCompiler extends BaseCompiler { async handleInterpreting(key, executeParameters) { executeParameters.args = [ '--emit=jit', - this.tslangSharedLib ? '--shared-libs=' + this.tslangSharedLib : '-nogc', + this.tslangSharedLib ? '--shared-libs=' + this.tslangSharedLib : '-mm=none', ...executeParameters.args, ]; @@ -54,7 +54,7 @@ export class TypeScriptCompiler extends BaseCompiler { if (!this.tslangSharedLib) { - newOptions.push('-nogc'); + newOptions.push('-mm=none'); } const output = await this.runCompilerRawOutput(this.tslangJit, newOptions, this.filename(inputFilename), execOptions); @@ -77,7 +77,7 @@ export class TypeScriptCompiler extends BaseCompiler { if (!this.tslangSharedLib) { - newOptions.push('-nogc'); + newOptions.push('-mm=none'); } const execOptions = this.getDefaultExecOptions(); diff --git a/docs/how/cmake_bgfx/CMakeLists.txt b/docs/how/cmake_bgfx/CMakeLists.txt index d69299535..4e90792a9 100644 --- a/docs/how/cmake_bgfx/CMakeLists.txt +++ b/docs/how/cmake_bgfx/CMakeLists.txt @@ -9,6 +9,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(TSLANG_ROOT "" CACHE PATH "TypeScriptCompiler __build folder (contains tslang/, llvm/, gc/)") +set(TSLANG_MEMORY_MODEL "gc" CACHE STRING "Memory model of compiled code: gc, rc or none") +set_property(CACHE TSLANG_MEMORY_MODEL PROPERTY STRINGS gc rc none) include(LocateTSLang) locate_tslang_compiler() @@ -49,6 +51,10 @@ else() set(CMAKE_TSLANG_FLAGS "--di --opt_level=0") endif() +# The same variable that picked the default-lib link directory has to reach the compiler as +# well, or the program is compiled for one model and linked against another model's default lib. +set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -mm=${TSLANG_MEMORY_MODEL}") + if(WIN32) else() set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -relocation-model=pic") @@ -66,9 +72,14 @@ target_include_directories(${PROJECT_NAME} PRIVATE native) set(TSLANG_LINK_LIBS TypeScriptDefaultLib TypeScriptAsyncRuntime - gc LLVMSupport) +# Boehm is only referenced by the gc default lib; the rc and none builds allocate through the +# CRT and must not drag a collector in. +if(TSLANG_MEMORY_MODEL STREQUAL "gc") + list(APPEND TSLANG_LINK_LIBS gc) +endif() + if(WIN32) list(APPEND TSLANG_LINK_LIBS ntdll) else() diff --git a/docs/how/cmake_bgfx/README.md b/docs/how/cmake_bgfx/README.md index f6d2cb917..e0c082c4d 100644 --- a/docs/how/cmake_bgfx/README.md +++ b/docs/how/cmake_bgfx/README.md @@ -100,6 +100,19 @@ cmake --preset debug cmake --build --preset debug ``` +### Memory model + +The default library is compiled separately for each memory model, and a program has to link the +build matching the model it was compiled with. One variable drives both: + +```bash +cmake --preset default -DTSLANG_MEMORY_MODEL=rc +``` + +It selects `defaultlib/lib//` as the link directory and adds `-mm=` +to the compile flags, so the two cannot disagree; configuring fails if that model has not been +built. Valid values are `gc` (default), `rc` and `none`; only `gc` links Boehm. + ## How it works 1. **`main_entry.cpp`** calls TypeScript `Main()`, then C++ `run_loop()`. diff --git a/docs/how/cmake_bgfx/cmake/LocateTSLang.cmake b/docs/how/cmake_bgfx/cmake/LocateTSLang.cmake index 02e19f3ca..52d4e1147 100644 --- a/docs/how/cmake_bgfx/cmake/LocateTSLang.cmake +++ b/docs/how/cmake_bgfx/cmake/LocateTSLang.cmake @@ -113,16 +113,29 @@ function(setup_tslang_link_paths) message(FATAL_ERROR "setup_tslang_link_paths: TSLANG_PREFIX is not set") endif() + # The compiled default lib is split per build mode (debug/release) and then per memory + # model (gc/rc/none): a default lib built for one model allocates the way that model + # allocates, so linking it into a program built for another is the mismatch the compiler + # refuses to paper over. TSLANG_MEMORY_MODEL picks both this directory and the -mm= flag. if(CMAKE_BUILD_TYPE STREQUAL "Release") set(_defaultlib_config "release") else() set(_defaultlib_config "debug") endif() + set(_defaultlib_dir + "${TSLANG_BIN_DIR}/defaultlib/lib/${_defaultlib_config}/${TSLANG_MEMORY_MODEL}") + if(NOT IS_DIRECTORY "${_defaultlib_dir}") + message(FATAL_ERROR + "No default library built for -mm=${TSLANG_MEMORY_MODEL}: ${_defaultlib_dir} " + "does not exist. Build it (see the default-lib build scripts), or select a model " + "that is built with -DTSLANG_MEMORY_MODEL=.") + endif() + set(_link_dirs "${TSLANG_BIN_DIR}" "${TSLANG_PREFIX}/lib" - "${TSLANG_BIN_DIR}/defaultlib/lib/${_defaultlib_config}") + "${_defaultlib_dir}") if(TSLANG_ROOT) if(EXISTS "${TSLANG_ROOT}/gc/release") diff --git a/docs/how/cmake_tslang/CMakeLists.txt b/docs/how/cmake_tslang/CMakeLists.txt index 6afabf2f9..4efb00f49 100644 --- a/docs/how/cmake_tslang/CMakeLists.txt +++ b/docs/how/cmake_tslang/CMakeLists.txt @@ -11,8 +11,21 @@ enable_language(TSLANG) # Include folders include_directories(${CMAKE_TSLANG_DIR}/defaultlib) +# The compiled default lib is split into per-build subfolders (debug/release) and then per +# memory model (gc/rc/none); pick the pair matching this build, so that the CRT, the allocator +# and the default-lib binaries all agree. A library built for one model cannot be linked into +# a program built for another. +if (CMAKE_BUILD_TYPE STREQUAL "Release") + set(TSLANG_DEFAULTLIB_BUILD "release") +else() + set(TSLANG_DEFAULTLIB_BUILD "debug") +endif() + +set(TSLANG_MEMORY_MODEL "gc" CACHE STRING "Memory model of compiled code: gc, rc or none") +set_property(CACHE TSLANG_MEMORY_MODEL PROPERTY STRINGS gc rc none) + # Lib folders -link_directories(${CMAKE_TSLANG_DIR} ${CMAKE_TSLANG_DIR}/defaultlib/lib) +link_directories(${CMAKE_TSLANG_DIR} ${CMAKE_TSLANG_DIR}/defaultlib/lib/${TSLANG_DEFAULTLIB_BUILD}/${TSLANG_MEMORY_MODEL}) # set options if (CMAKE_BUILD_TYPE STREQUAL "Release") @@ -21,6 +34,10 @@ else() set(CMAKE_TSLANG_FLAGS "--di --opt_level=0") # global endif() +# The same variable that picked the link directory has to reach the compiler as well, or the +# program is compiled for one model and linked against another model's default lib. +set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -mm=${TSLANG_MEMORY_MODEL}") # global + if(WIN32) else() set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -relocation-model=pic") # global @@ -34,7 +51,13 @@ add_executable(${PROJECT_NAME} ) # required libs -set(TSLANG_LINK_LIBS "TypeScriptDefaultLib" "TypeScriptAsyncRuntime" "gc" "LLVMSupport") +set(TSLANG_LINK_LIBS "TypeScriptDefaultLib" "TypeScriptAsyncRuntime" "LLVMSupport") + +# Boehm is only referenced by the gc default lib; the rc and none builds allocate through the +# CRT and must not drag a collector in. +if (TSLANG_MEMORY_MODEL STREQUAL "gc") + list(APPEND TSLANG_LINK_LIBS "gc") +endif() # ntdll provides RtlGetLastNtStatus (pulled in by LLVMSupport) on Windows if(WIN32) diff --git a/docs/how/cmake_tslang/README.md b/docs/how/cmake_tslang/README.md index 987856eb2..2c5a2abc1 100644 --- a/docs/how/cmake_tslang/README.md +++ b/docs/how/cmake_tslang/README.md @@ -43,6 +43,19 @@ set_source_files_properties(mycode.ts PROPERTIES COMPILE_OPTIONS "--define;TSLANG=1") # per-file ``` +## Memory model + +The default library is compiled separately for each memory model, and a program has to link +the build matching the model it was compiled with. One variable drives both: + +``` +cmake --preset default -DTSLANG_MEMORY_MODEL=rc +``` + +It selects `defaultlib/lib//` as the link directory and adds `-mm=` +to the compile flags, so the two cannot disagree. Valid values are `gc` (default), `rc` and +`none`; only `gc` links Boehm. + ## Minimal alternative If you don't need a first-class language, either: diff --git a/docs/how/wasm/test/test_wasm.ps1 b/docs/how/wasm/test/test_wasm.ps1 index a3d626396..c7dd099eb 100644 --- a/docs/how/wasm/test/test_wasm.ps1 +++ b/docs/how/wasm/test/test_wasm.ps1 @@ -17,7 +17,7 @@ $files | ForEach-Object -Parallel { $outFileName = $file.BaseName + ".wasm" $stdOutputFileName = $file.BaseName + ".txt" $errOutputFileName = $file.BaseName + ".err" - $argumentList = "--emit=exe", "--nogc", "-mtriple=wasm32-unknown-unknown", "-o=$outFileName", $file.FullName + $argumentList = "--emit=exe", "-mm=none", "-mtriple=wasm32-unknown-unknown", "-o=$outFileName", $file.FullName if (Test-Path -Path $outFileName -PathType Leaf) diff --git a/docs/how/wasm/wasm/tsc_wasm.bat b/docs/how/wasm/wasm/tsc_wasm.bat index 529f6761f..9d6139537 100644 --- a/docs/how/wasm/wasm/tsc_wasm.bat +++ b/docs/how/wasm/wasm/tsc_wasm.bat @@ -1,5 +1,5 @@ set GC_LIB_PATH=C:\dev\TypeScriptCompiler\__build\gc\msbuild\x64\debug\Debug set LLVM_LIB_PATH=C:\dev\TypeScriptCompiler\__build\llvm\msbuild\x64\debug\Debug\lib set TSLANG_LIB_PATH=C:\dev\TypeScriptCompiler\__build\tslang\windows-msbuild-debug\lib -C:\dev\TypeScriptCompiler\__build\tslang\windows-msbuild-debug\bin\tslang.exe --emit=exe --nogc --di -mtriple=wasm32-unknown-unknown C:\temp\1.ts +C:\dev\TypeScriptCompiler\__build\tslang\windows-msbuild-debug\bin\tslang.exe --emit=exe -mm=none --di -mtriple=wasm32-unknown-unknown C:\temp\1.ts copy 1.wasm C:\temp\webassembly3\hello.wasm \ No newline at end of file diff --git a/docs/how/wasm/wasm/tsc_wasm_emscripten.bat b/docs/how/wasm/wasm/tsc_wasm_emscripten.bat index f9ed9055b..a3d031222 100644 --- a/docs/how/wasm/wasm/tsc_wasm_emscripten.bat +++ b/docs/how/wasm/wasm/tsc_wasm_emscripten.bat @@ -2,5 +2,5 @@ set GC_LIB_PATH=C:\dev\TypeScriptCompiler\__build\gc\msbuild\x64\debug\Debug set LLVM_LIB_PATH=C:\dev\TypeScriptCompiler\__build\llvm\msbuild\x64\debug\Debug\lib set TSLANG_LIB_PATH=C:\dev\TypeScriptCompiler\__build\tslang\windows-msbuild-debug\lib set EMSDK_SYSROOT_PATH=C:\utils\emsdk\upstream\emscripten\cache\sysroot -C:\dev\TypeScriptCompiler\__build\tslang\windows-msbuild-debug\bin\tslang.exe --emit=exe --nogc -mtriple=wasm32-pc-emscripten C:\temp\1.ts +C:\dev\TypeScriptCompiler\__build\tslang\windows-msbuild-debug\bin\tslang.exe --emit=exe -mm=none -mtriple=wasm32-pc-emscripten C:\temp\1.ts copy 1.wasm C:\temp\webassembly3\hello.wasm \ No newline at end of file diff --git a/docs/jit-gc-static-roots.md b/docs/jit-gc-static-roots.md index 6503aadef..850a2873f 100644 --- a/docs/jit-gc-static-roots.md +++ b/docs/jit-gc-static-roots.md @@ -18,7 +18,7 @@ Characteristic behaviour that pointed at the garbage collector: | AOT (`--emit=exe`) at O0 | passes | | JIT at O0, 1x1 or 16x16 render | passes | | JIT at O0, 48x48 and larger | crashes | -| JIT at O0 with `--nogc` | passes | +| JIT at O0 with `-mm=none` | passes | The crash appears exactly when the Boehm heap grows enough to trigger the first collection cycle. @@ -64,7 +64,7 @@ function main() { Before the fix this printed `2.9751e+006` — i.e. the static's memory had been recycled as `Vec(i, i, i)` with `i ≈ 2975100`, an allocation from the last -collection cycle. With `--nogc` it printed `11`. +collection cycle. With `-mm=none` it printed `11`. ### Fix @@ -80,7 +80,7 @@ GC_remove_roots(base, base + size); // on release `GC_add_roots`/`GC_remove_roots` are resolved at run time with `llvm::sys::DynamicLibrary::SearchForAddressOfSymbol` from the already-loaded -`TypeScriptRuntime` library, so the mapper is inert under `--nogc` or when no +`TypeScriptRuntime` library, so the mapper is inert under `-mm=none` or when no GC runtime is present. The exports were added as thin wrappers (`_mlir__GC_add_roots`, `_mlir__GC_remove_roots`) in `lib/TypeScriptRuntime/gc.cpp` and re-exported under the plain names in @@ -125,7 +125,7 @@ copy of the runtime — and therefore one GC — is loaded per JIT process. ## How to diagnose this class of bug -1. `--nogc` passing while the normal run crashes ⇒ collector involvement. +1. `-mm=none` passing while the normal run crashes ⇒ collector involvement. 2. Crash threshold scaling with allocation volume ⇒ first collection cycle. 3. AOT passing while JIT fails ⇒ suspect JIT-only differences: static roots, symbol resolution, unwind info. diff --git a/docs/memory-models.md b/docs/memory-models.md new file mode 100644 index 000000000..efcb56afc --- /dev/null +++ b/docs/memory-models.md @@ -0,0 +1,150 @@ +# Memory models + +The compiler can manage heap memory in three ways, selected with `-mm=`: + +| flag | what it does | when to use it | +| --- | --- | --- | +| `-mm=gc` | **Garbage collection** (Boehm). The default. | Almost always. Nothing to think about, and nothing leaks. | +| `-mm=rc` | **Reference counting.** Objects are freed the moment the last reference to them goes away. No collector, no pauses, no runtime dependency on libgc. | Predictable latency, WebAssembly, or shipping without libgc — **provided you read the cycles section below.** | +| `-mm=none` | **Nothing is ever freed.** | Short-lived programs where the process exits before memory matters. | + +`-mm=gc` is the default and stays the default. Everything below is about what changes if you +choose `-mm=rc`. + +```bash +tslang --emit=exe -mm=rc hello.ts +``` + +## What `-mm=rc` gives you + +Memory is reclaimed deterministically, at the point the last reference is dropped, rather than +whenever a collector next runs. On allocation-heavy programs it holds close to the working set +rather than to the total allocated: + +| program | `-mm=gc` | `-mm=rc` | `-mm=none` | +| --- | --- | --- | --- | +| a ray tracer, 256x256 | 5.8 MB | 4.1 MB | 114.5 MB | +| the same, 512x512 | 5.5 MB | 4.1 MB | 445.0 MB | +| an n-body simulation | 5.6 MB | 4.1 MB | 4.1 MB | + +Peak working set, ahead-of-time build, `--opt --opt_level=3`. The ray tracer's `-mm=rc` figure is +flat across a 64-fold change in image size, because nothing accumulates. + +There is no collector thread, no pause, and no `libgc` to ship. + +## Reference cycles are not collected + +**This is the one thing to know before choosing `-mm=rc`.** If two objects refer to each other, +directly or through a chain, neither one's count ever reaches zero and neither is ever freed: + +```typescript +class Node { + parent: Node; + name: string; + constructor(name: string) { this.name = name; } +} + +const a = new Node("a"); +const b = new Node("b"); +a.parent = b; +b.parent = a; // a cycle: neither a nor b will ever be freed under -mm=rc +``` + +Measured, that costs everything: a loop building one such pair per iteration holds **22.6 MB** +under `-mm=rc`, exactly what `-mm=none` holds — reference counting reclaims none of it. Break +the cycle by removing one of the two assignments and the same loop holds 4.1 MB. + +This is a **defined property of the mode, not a bug**, and it is the same trade Swift makes with +ARC. The difference is that here it is opt-in: `-mm=gc` is the default, handles cycles without +you thinking about it, and is one flag away. + +### Shapes that leak + +Anything that refers back to itself, however indirectly. Each of these was measured, and each +holds exactly as much as `-mm=none` — that is, reference counting reclaims none of it: + +- **Parent/child links** — `class Node { parent: Node; children: Node[] }`, the example above. +- **Mutually referencing objects** — `a.peer = b; b.peer = a`. +- **Doubly linked lists** — every node holds its neighbour and is held by it. +- **An object holding a callback that captures the object** — the closure's capture box holds + the object, and the object holds the closure. + +### Shapes that do not leak + +- **Trees and lists with no back-references** — the common case. +- **Strings**, and arrays of them. A string never points at another heap object, so no cycle + involving one can exist. +- **Self-recursive functions.** A named recursive function is not a cycle — it holds no + reference to itself at run time. (A self-referential *arrow function* would be, but the + compiler does not currently accept one.) + +### What to do about it + +1. **Use `-mm=gc`** — the default — if your data has cycles and you do not want to think about + them. This is the right answer for most programs. +2. **Break the cycle by hand** where you know about it: null out the back-reference when you are + done with the structure, or store a key/index instead of a pointer back to the owner. +3. If neither fits, `-mm=rc` is not the right mode for that program. + +A `WeakRef` that lets you declare a back-reference as non-owning is designed but not +implemented; see `tslang/docs/reference-counting-evaluation.md` §9.8. When it lands it will be +the fourth option here, and it does not change anything above. + +## The standard library + +The standard library is built once per memory model, and your program links the build matching +its own `-mm=`. Nothing to configure — the compiler picks it. + +It has to work that way: the library allocates the way the model it was built for allocates. +The `-mm=gc` build calls into Boehm and brings `libgc` with it; the `-mm=rc` build maintains +reference counts and brings no collector at all. A hello-world is 335 KB under `-mm=gc` and +145 KB under `-mm=rc` for exactly that reason. + +If the build for your model is missing, the compiler says so and names the directory rather +than falling back to another model's copy — that would link and then misbehave at run time. +Build them with the default library's `build.bat` (`build.sh` on Linux), which produces all +three. + +## Other limits of `-mm=rc` + +- **Objects crossing between differently-managed modules are never freed.** If you link a + module built `-mm=rc` against one built `-mm=gc`, anything allocated on the other side leaks + rather than being freed twice. The compiler warns when it can see the mismatch. Building + everything with the same `-mm=` avoids it. (The standard library is not affected — see + above.) +- **Counts are not atomic.** `-mm=rc` is single-threaded today. + +## Shared libraries and `-mm=gc` + +**A program that loads a tslang shared library must link Boehm as a DLL, not statically.** + +If the executable and the library each link `gc.lib` statically, each gets its own collector, +with its own heap and its own idea of what the roots are. The library's collector does not scan +the executable's roots, so it frees objects the executable is still holding. The symptom is not +a crash: the freed memory is reallocated and the program reads a plausible wrong value, which +only shows up when what was written over it differs from what was there. + +The Windows release package ships the shared collector in its `gcdll` folder, beside the static +`gc.lib` at its root — both files are named `gc.lib`, so the folder is what tells them apart. +Compile the executable **and** every shared library with `--gc-lib-path=/gcdll`, and +ship `gcdll/gc.dll` beside the executable. + +From a source build, the same files come from `scripts/build_gc_release_shared_vs.bat`: link +against `3rdParty/gcdll/x64/release/lib/gc.lib` and ship `3rdParty/gcdll/x64/release/bin/gc.dll`. + +Statically linked programs are unaffected and keep the static `gc.lib` — one binary already +means one collector. `-mm=rc` and `-mm=none` are unaffected either way: neither has a collector. + +## Mixing modules + +A shared library records the model it was built under, and the compiler warns when you import +one built differently: + +``` +warning: shared library 'foo.dll' was built with -mm=gc, this module with -mm=rc. +Objects crossing between them are never reclaimed. +``` + +The link is allowed and the program is correct — objects that cross simply leak, rather than +being freed by one side while the other still holds them. Build every module with the same +`-mm=` to avoid it. diff --git a/scripts/build_gc_debug_shared_vs.bat b/scripts/build_gc_debug_shared_vs.bat new file mode 100644 index 000000000..ecabfc0db --- /dev/null +++ b/scripts/build_gc_debug_shared_vs.bat @@ -0,0 +1,18 @@ +@rem Debug counterpart of build_gc_release_shared_vs.bat - Boehm built as a DLL, installed +@rem beside the static debug one. +@rem +@rem See that script for why the shared collector exists at all (item 5ao: two statically +@rem linked collectors free each other's objects). This one exists because the shared-library +@rem tests are registered per configuration and look for gc.lib under +@rem 3rdParty/gcdll/x64/: with only the release build installed, a debug tree has no +@rem shared collector and every -shared test fails to link with "could not open 'gc.lib'". +@rem +@rem MultiThreadedDebug (/MTd) to match the debug test linker, which links libcmtd/libvcruntimed/ +@rem libucrtd - mixing static and dynamic, or debug and release, CRTs crashes at startup. +pushd +mkdir __build\gcdll\msbuild\x64\debug +cd __build\gcdll\msbuild\x64\debug +cmake ../../../../../3rdParty/gc-8.2.12 -G "Visual Studio 18 2026" -A x64 %EXTRA_PARAM% -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON -Wno-dev -DCMAKE_INSTALL_PREFIX=../../../../../3rdParty/gcdll/x64/debug -Denable_threads=ON -Denable_cplusplus=OFF -DCMAKE_POLICY_DEFAULT_CMP0091=NEW -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDebug +cmake --build . --config Debug -j 8 +cmake --install . --config Debug +popd diff --git a/scripts/build_gc_release_shared_vs.bat b/scripts/build_gc_release_shared_vs.bat new file mode 100644 index 000000000..bcafce7be --- /dev/null +++ b/scripts/build_gc_release_shared_vs.bat @@ -0,0 +1,17 @@ +@rem Boehm built as a DLL, installed beside the static one. +@rem +@rem A program that loads a tslang shared library ends up with TWO collectors when both the +@rem executable and the library link gc.lib statically: each has its own heap and its own idea +@rem of what the roots are. Objects the library allocates are then invisible to the executable's +@rem roots, so the collector frees strings the executable is still holding. See item 5ao in +@rem tslang/docs/reference-counting-evaluation.md. +@rem +@rem Static linking stays the default and is correct on its own - one binary, one collector. +@rem This build exists for the shared case, where one collector has to be shared too. +pushd +mkdir __build\gcdll\msbuild\x64\release +cd __build\gcdll\msbuild\x64\release +cmake ../../../../../3rdParty/gc-8.2.12 -G "Visual Studio 18 2026" -A x64 %EXTRA_PARAM% -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -Wno-dev -DCMAKE_INSTALL_PREFIX=../../../../../3rdParty/gcdll/x64/release -Denable_threads=ON -Denable_cplusplus=OFF -DCMAKE_POLICY_DEFAULT_CMP0091=NEW -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded +cmake --build . --config Release -j 8 +cmake --install . --config Release +popd diff --git a/scripts/measure_memory_model.ps1 b/scripts/measure_memory_model.ps1 new file mode 100644 index 000000000..08a65a21c --- /dev/null +++ b/scripts/measure_memory_model.ps1 @@ -0,0 +1,94 @@ +# Builds a native executable per memory model, runs it, and reports peak working set. +# +# Recreated from the description in docs/reference-counting-evaluation.md section 9.52, with one +# change. That section samples PeakWorkingSet64 in a spin loop and warns never to sleep in it, +# because the counter reads zero once the process has exited. The warning is right and the +# technique is still a race: a program that finishes before the first sample reads 0.0 MB, which +# is what it did on the first run here. +# +# The kernel keeps the peak for as long as a handle to the process is open, exited or not, so +# GetProcessMemoryInfo answers after WaitForExit with no sampling at all. Start-Process -PassThru +# holds that handle. No loop, no race, and it costs nothing. +# +# What section 9.52 insists on and is kept: the exit code is printed. A crashed process reports a +# small number and looks like a win - section 9.43's famous 2.6 MB was a process that had died. +param( + [string]$Second = "", + [Parameter(Mandatory=$true)][string]$Source, + [string[]]$Models = @("gc","rc","none"), + [string]$Opt = "--opt --opt_level=3" +) + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class PeakWs { + [StructLayout(LayoutKind.Sequential)] + struct PROCESS_MEMORY_COUNTERS { + public uint cb; + public uint PageFaultCount; + public IntPtr PeakWorkingSetSize; + public IntPtr WorkingSetSize; + public IntPtr QuotaPeakPagedPoolUsage; + public IntPtr QuotaPagedPoolUsage; + public IntPtr QuotaPeakNonPagedPoolUsage; + public IntPtr QuotaNonPagedPoolUsage; + public IntPtr PagefileUsage; + public IntPtr PeakPagefileUsage; + } + [DllImport("psapi.dll", SetLastError=true)] + static extern bool GetProcessMemoryInfo(IntPtr h, out PROCESS_MEMORY_COUNTERS c, uint size); + + // Readable after the process has exited, for as long as the handle is open - which is the + // whole reason this exists instead of a sampling loop. + public static long Of(IntPtr handle) { + PROCESS_MEMORY_COUNTERS c; + c.cb = 0; + if (!GetProcessMemoryInfo(handle, out c, (uint)Marshal.SizeOf(typeof(PROCESS_MEMORY_COUNTERS)))) + return -1; + return (long)c.PeakWorkingSetSize; + } +} +"@ + +$bin = "I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin" +$lib = "I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/lib" +$lld = "I:/TypeScriptCompiler/tslang/../3rdParty/llvm/x64/release/bin" +$llvmlib = "I:/TypeScriptCompiler/tslang/../3rdParty/llvm/x64/release/lib" +$gclib = "I:/TypeScriptCompiler/3rdParty/gc/x64/release/lib" +$vclib = "C:/Program Files/Microsoft Visual Studio/18/Professional/VC/Tools/MSVC/14.51.36231/lib/x64" +$sdk = "C:/Program Files (x86)/Windows Kits/10/Lib/10.0.28000.0/um/x64" +$ucrt = "C:/Program Files (x86)/Windows Kits/10/Lib/10.0.28000.0/ucrt/x64" + +$libs = "libcmt.lib libvcruntime.lib libucrt.lib ntdll.lib TypeScriptAsyncRuntime.lib gc.lib LLVMSupport.lib kernel32.lib user32.lib gdi32.lib winspool.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib" + +$stem = [System.IO.Path]::GetFileNameWithoutExtension($Source) +$work = Join-Path $env:TEMP "measure-$stem" +New-Item -ItemType Directory -Force -Path $work | Out-Null + +foreach ($m in $Models) { + $obj = Join-Path $work "$stem-$m.obj" + $exe = Join-Path $work "$stem-$m.exe" + + $compile = & "$bin/tslang.exe" --emit=obj $Opt.Split(' ') --no-default-lib "-mm=$m" $Source "-o=$obj" 2>&1 + if ($LASTEXITCODE -ne 0) { "{0,-5} COMPILE FAILED ({1})" -f $m, $LASTEXITCODE; $compile | Select-Object -Last 3; continue } + + # A second module is compiled to its own object and linked in, which is what test-runner + # does for the `import_*`/`export_*` pairs: same flags, same -mm, one link. + $objs = @($obj) + if ($Second -ne "") { + $obj2 = Join-Path $work ([System.IO.Path]::GetFileNameWithoutExtension($Second) + "-$m.obj") + $c2 = & "$bin/tslang.exe" --emit=obj $Opt.Split(' ') --no-default-lib "-mm=$m" $Second "-o=$obj2" 2>&1 + if ($LASTEXITCODE -ne 0) { "{0,-5} COMPILE2 FAILED ({1})" -f $m, $LASTEXITCODE; $c2 | Select-Object -Last 3; continue } + $objs += $obj2 + } + + $link = & "$lld/lld.exe" -flavor link $objs "/out:$exe" $libs.Split(' ') ` + "/libpath:$gclib" "/libpath:$llvmlib" "/libpath:$lib" "/libpath:$vclib" "/libpath:$sdk" "/libpath:$ucrt" 2>&1 + if ($LASTEXITCODE -ne 0) { "{0,-5} LINK FAILED ({1})" -f $m, $LASTEXITCODE; $link | Select-Object -Last 3; continue } + + $p = Start-Process -FilePath $exe -PassThru -NoNewWindow -RedirectStandardOutput "$work\$stem-$m.out" -RedirectStandardError "$work\$stem-$m.err" + $p.WaitForExit() + $peak = [PeakWs]::Of($p.Handle) + "{0,-5} peak {1,8:N1} MB exit {2}" -f $m, ($peak/1MB), $p.ExitCode +} diff --git a/tag.bat b/tag.bat index e55c895cc..497c9a7f7 100644 --- a/tag.bat +++ b/tag.bat @@ -1,2 +1,2 @@ -git tag -a v0.0-pre-alpha80 -m "pre alpha v0.0-80" +git tag -a v0.0-pre-alpha81 -m "pre alpha v0.0-81" git push origin --tags diff --git a/tag_del.bat b/tag_del.bat index 8a92db915..aa2f44e08 100644 --- a/tag_del.bat +++ b/tag_del.bat @@ -1,2 +1,2 @@ -git push --delete origin v0.0-pre-alpha80 -git tag -d v0.0-pre-alpha80 +git push --delete origin v0.0-pre-alpha81 +git tag -d v0.0-pre-alpha81 diff --git a/tslang/build_tslang_defaultlib_debug.bat b/tslang/build_tslang_defaultlib_debug.bat index 19525bad3..4f66ccd72 100644 --- a/tslang/build_tslang_defaultlib_debug.bat +++ b/tslang/build_tslang_defaultlib_debug.bat @@ -3,7 +3,8 @@ cd ../../TypeScriptCompilerDefaultLib/ call build.bat rem Copy the whole staged defaultlib tree so per-build subfolders are preserved: -rem defaultlib\dll\{debug,release}, defaultlib\lib\{debug,release}, *.d.ts, generics\ +rem defaultlib\dll\{debug,release}\{gc,rc,none}, defaultlib\lib\{debug,release}\{gc,rc,none}, +rem *.d.ts, generics\ xcopy __build\defaultlib\*.* "../TypeScriptCompiler/__build/tslang/windows-msbuild-2026-debug/bin/defaultlib/" /i /e /y popd diff --git a/tslang/build_tslang_defaultlib_release.bat b/tslang/build_tslang_defaultlib_release.bat index 5c85998c0..80db6aeef 100644 --- a/tslang/build_tslang_defaultlib_release.bat +++ b/tslang/build_tslang_defaultlib_release.bat @@ -3,7 +3,8 @@ cd ../../TypeScriptCompilerDefaultLib/ call build.bat rem Copy the whole staged defaultlib tree so per-build subfolders are preserved: -rem defaultlib\dll\{debug,release}, defaultlib\lib\{debug,release}, *.d.ts, generics\ +rem defaultlib\dll\{debug,release}\{gc,rc,none}, defaultlib\lib\{debug,release}\{gc,rc,none}, +rem *.d.ts, generics\ xcopy __build\defaultlib\*.* "../TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin/defaultlib/" /i /e /y popd diff --git a/tslang/docs/reference-counting-evaluation.md b/tslang/docs/reference-counting-evaluation.md new file mode 100644 index 000000000..519372580 --- /dev/null +++ b/tslang/docs/reference-counting-evaluation.md @@ -0,0 +1,5896 @@ +# Reference Counting as a Memory-Model Option + +Status: **evaluation only, nothing implemented.** Written 2026-09-02 against `main`, +revised the same day after the framing correction below. + +> **Framing.** RC is evaluated here as a **selectable memory model alongside GC** +> (`-mm=rc`), not as a replacement for it. GC stays the default. This is the right +> framing, and it changes the conclusion: the cycle problem stops being a blocker and +> becomes an opt-in tradeoff, and delivery can be incremental. It also introduces one +> problem a replacement never had — two models must coexist in one compiler and, worse, +> in one link. + +## Verdict + +**Viable as an option, and the option framing is what makes it viable.** Three things +follow, in priority order: + +1. **The ABI decision must be made before any code is written.** It is the only decision + here that cannot be retrofitted. See §4 — this is the new central risk and it did not + exist under the replacement framing. +2. **The per-gap engineering cost does not go down.** Everything in §3 is still required + in full for `-mm=rc` to work at all. What changes is who bears the risk, and that the + work can ship in stages behind a flag instead of landing complete. +3. **The permanent cost is two memory models in MLIRGen forever.** Not a one-time build + cost — a standing tax on every future language feature. That is the real thing to + weigh, and it is a judgment about project capacity, not a technical blocker. + +--- + +## 1. What the GC integration actually is today + +This matters because the current design is what makes the option look cheap when it is not. + +GC is wired in by **name substitution at the very end of the pipeline**. Nothing in +the IR, the type system, or MLIRGen knows a collector exists. + +| Piece | Location | Role | +| --- | --- | --- | +| `GCPass` | `lib/TypeScript/GCPass.cpp` (274 lines) | Runs *after* `LowerToLLVMPass`. Renames `malloc`/`calloc`/`realloc`/`free`/`aligned_alloc` to `GC_malloc`/`GC_malloc_atomic`/`GC_realloc`/`GC_free`/`GC_memalign`; injects `GC_init()`; attaches `allockind("alloc")` so `-O3` GVN does not CSE two allocations into one; drops the `memset` after `GC_malloc`. | +| Allocation funnel | `LLVMCodeHelperBase.h:253` `_MemoryAlloc` | Every heap allocation in the compiler goes through here and emits a plain `malloc` call. **Twelve** call sites total. | +| Typed heap | `MLIRGenClasses.cpp:1322` `mlirGenClassTypeBitmap` | Emits a per-class pointer/non-pointer bitmap, cached in a global, fed to `GC_make_descriptor` / `GC_malloc_explicitly_typed`. | +| Pipeline order | `tslang/transform.cpp:161` | `GCPass` is the last pass, gated on `!disableGC`. | + +The consequence for an *option*: GC's selectability is nearly free because GC needs no +program knowledge, so its entire branch point is one late pass. RC needs the most program +knowledge of anything in the compiler and must branch in MLIRGen, before lowering discards +type and ownership information. **The two models cannot be made selectable at the same +place in the pipeline.** That asymmetry is what the rest of this document is about. + +### 1.1 The option mechanism already exists + +`CompileOptions` (`include/TypeScript/DataStructs.h`) is a plain struct threaded through +MLIRGen, both lowering passes, and `GCPass`. `disableGC` already rides it end to end +(`opts.cpp:45` → `transform.cpp:161`). Adding `memoryModel` is mechanically identical. + +One cleanup this should force: `-nogc` today means *leak everything* — `malloc` with no +`free`. With RC added there are three models, so the flag should become +`-mm={gc,rc,none}` with `-nogc` kept as an alias, rather than two independent booleans +that can contradict each other. **Done 2026-09-03 — see §9.6.** + +## 2. What already exists to build on + +Four assets. They are why a staged approach is viable rather than a standing start. + +- **Pointer-layout metadata per class.** `mlirGenClassTypeBitmap` already computes which + fields of a class are pointers, by generating code that takes field addresses off a null + base. Today it produces a Boehm descriptor word; the same data is exactly what a + recursive release routine needs. +- **A scope-exit walker.** `mlirGenDisposable` (`MLIRGenImpl.h:511`) already walks out of + scopes calling `Symbol.dispose` on `using` variables, with `CurrentScope` / `LoopScope` / + `FullStack` depth semantics for `break`/`continue`/`return`. That is the shape release + calls need. +- **RC precedent in-pipeline.** `transform.cpp:142` already runs MLIR's + `createAsyncRuntimeRefCountingPass()` (plus its `Opt` variant under `-O`). Liveness-based + automatic RC on async values runs in this compiler today. The technique is proven here. +- **A test-runner pattern for option variants.** `-fast-math` tests already get their own + cached script names (`jitfm` / `compilefm`) because the plain `jit`/`compile` scripts are + shared across parallel single-file tests and embed the flag string at creation time + (`test-runner.cpp:85-108`). `-mm=rc` reuses that pattern directly. + +## 3. What RC requires that does not exist + +Six gaps. **The option framing reduces none of them** — each is still required in full +before `-mm=rc` produces a correct program. Ordered roughly by cost. + +### 3.1 Object headers (the ABI decision, see §4) + +Nothing on the heap has a header. There is nowhere to put a count. + +- `string` lowers to a bare pointer (`LowerToLLVM.cpp:6101`) and is handed **straight to + libc**: `strlen` (`:485`, `:572`), `strcpy`/`strcat` (`:573`, `:574`), `strcmp` + (`:635`, `:849`, `:895`, `:1004`), `puts` (`:164`). A header can live *before* the + returned pointer so libc still works, but every release site must then recover + `ptr - sizeof(header)`, and every pointer from elsewhere must not. +- `array` lowers to a by-value `{dataPtr, length}` struct (`LowerToLLVM.cpp:6115`). +- A class instance is a raw pointer to its storage struct, field 0 being the vtable. + +### 3.2 Literal-versus-heap discrimination + +String literals and const arrays are LLVM globals, not heap blocks, but they flow into the +same SSA values as heap results: + +```ts +let s = cond ? "literal" : a + b; // sometimes a global, sometimes heap +``` + +Releasing a global is a crash. Needs a saturating "immortal" count the globals also carry, +or a pointer tag. Boehm needs neither — it ignores addresses outside its heap. + +### 3.3 An ownership model in MLIRGen + +The bulk of the work, with no shortcut. Values are plain `mlir::Value` with no +owned/borrowed distinction. Retain and release decisions are needed at every assignment, +field store, element store, argument pass, return, capture, and box-into-`any`, across the +largest and most intricate part of the codebase. + +**This is also where the two models permanently diverge.** GC mode needs none of it. Every +future language feature has to be correct under both. + +> **Started 2026-09-03 (§9.12).** Locals now own what they hold. The "correct under both" +> tax turned out smaller than written here: ownership is stated once, in ops that erase under +> a collector, so MLIRGen carries no second model - only a second lowering does. + +### 3.4 Type-erased release + +`any` boxes as `{size, typeNamePtr, payload}` (`AnyLogic.h:48`) where the type tag is a +**type-name string**, under a standing `// TODO: add type id to track data type`. To +release an `any`'s payload you must know whether it holds a pointer and which routine +frees it. There is no id-to-release-function table. Tagged unions have the same problem. + +> **Addressed 2026-09-03 (§9.3).** Every tag now points into a per-concrete-type descriptor +> carrying a kind id and a reserved release slot. The table step 4 needs has somewhere to +> live; nothing fills it in yet. + +### 3.5 Cleanup landing pads + +`ENABLE_EXCEPTIONS` is on. Every throw path must release the live owned values in each +frame it unwinds. Existing landing pads (`LowerToLLVM.cpp:4402`) do catch dispatch only. +This is the classic source of RC bugs that surface only under exceptions. + +### 3.6 Interior references + +`BoundRefType` lowers to `{ptr, ptr}` and `GetReferenceFromValue` hands out references to +object *fields*. An interior reference must keep its owner alive. Boehm handles this free +via interior-pointer scanning; RC would need those values widened to carry and retain the +owner. + +## 4. The new central problem: two models in one link + +> **Decided 2026-09-03: allow mixed links, treating what crosses as immortal** — leak rather +> than double-free, chosen over a hard error because an error forces a per-model default lib. +> The marker this needs landed in §9.7; the marking itself lands with ownership insertion. + +This risk **does not exist under the replacement framing** and is the single most important +finding of the revision. + +There are 72 cross-module tests (`import_*` / `export_*`), and heap objects cross module +boundaries in both directions: a consumer allocates instances of an imported class through +its own synthesized `.new`, while an exporting module's own code allocates objects the +consumer then holds and mutates. The declaration mechanism is source-text re-print and +re-parse (`declExports`, `MLIRGenImpl.h:11304`), so **each side compiles its own view under +its own `CompileOptions`.** + +Nothing today prevents a GC-built shared library from being linked against an RC-built +consumer. If RC adds a header and GC does not, then: + +- RC-side code computes `ptr - sizeof(header)` on an object a GC-built module allocated + without one, and decrements whatever precedes it in the heap. +- GC-side code hands out objects that RC-side scope exits will release and free. + +Both are **silent memory corruption**, not a link error. Given how much of this project's +recent history is cross-module work, this would be a persistent, hard-to-diagnose class of +bug. + +There are exactly two acceptable answers, and the choice must be made before any code is +written because it is not retrofittable: + +**(a) Emit the header in both modes — recommended.** GC builds pay one word per heap object +and ignore it. The ABI becomes uniform, mixed linking is safe, and the ABI change lands and +is tested *under GC*, where a wrong count is harmless. This also makes §3.1 a mode-neutral +change that can ship long before any RC semantics exist. + +**(b) Forbid mixed linking and fail loudly.** Emit a memory-model marker into `declExports` +(it is text and re-parsed, so this is cheap) and additionally reference a mode-specific +sentinel symbol so a mismatch fails at link time rather than at runtime. + +Doing neither is the worst outcome. (a) and (b) are not exclusive; (a) plus the marker from +(b) is the strongest position. + +## 5. Cycles: a blocker under replacement, a documented tradeoff as an option + +> **Decided 2026-09-03: weak references in the language, spelled `WeakRef`.** Not +> leak-and-document. Representation settled in §9.8 — a weak count in front of the strong one, +> so `-mm=gc` builds keep their single header word. + +Plain RC leaks cycles, and here the cycles are not exotic: + +- **Recursive closures are a compiler-generated cycle.** Capture records are heap allocated + (`LowerToAffineLoops.cpp:2106`, `ALLOC_CAPTURE_IN_HEAP`) and hold the captured values. A + self-referential arrow function stores its own `HybridFunction` `{funcPtr, captureBoxPtr}` + *into the very box that pointer names*. The compiler emits this, not an unusual program. +- Ordinary user cycles: `class Node { parent: Node; children: Node[] }`, a generator holding + `this` while `this` holds the generator, mutually referencing objects. + +Of the 453 tests in `test/tester/tests`: 159 use classes, 86 use arrow functions, 22 use +generators. + +**As an option this is acceptable and has direct precedent.** Swift ships ARC as its only +model and leaks cycles by design, mitigated by `weak`/`unowned` and documentation. Here GC +remains the default, so a user selecting `-mm=rc` is making the same informed trade Swift +users make, and the safe model is one flag away. What this requires is honesty rather than +a solution: + +- Document cycle leakage as a defined property of the mode, not a bug. +- Decide whether to add a weak-reference annotation. TypeScript has no surface syntax for + it, so this is a language extension and should be a separate decision, not a prerequisite. +- A trial-deletion cycle collector (Bacon-Rajan) remains available later and is a second + collector. As an *option* that is at least coherent, where under replacement it defeated + the purpose. + +*Not* a cycle, worth recording because it looks like one: object-literal method fields +store an **unbound** function pointer. `getEffectiveFunctionTypeForTupleField` +(`MLIRCodeLogic.h:158`) strips the bound-ness for storage, and `this` is re-bound at load +time (`LowerToLLVM.cpp:5157`). A method-bearing object does not hold a pointer to itself. + +## 6. Standing costs of carrying two models + +Distinct from build cost. These do not end when the feature ships. + +| Cost | Detail | +| --- | --- | +| **MLIRGen carries two models** | Every future language feature must be correct under both, or explicitly unsupported under RC. Given this project's cadence of interface/generator/cross-module fixes, this compounds indefinitely. | +| **Test matrix roughly doubles** | 453 tests, for whatever subset RC claims to support. The `jitfm`/`compilefm` pattern (`test-runner.cpp:85-108`) extends to `jitrc`/`compilerc`, so the mechanism exists; the CI time is the cost. | +| **Mixed-link surface** | Permanent, per §4, unless the uniform-header answer is taken. | +| **Flag surface** | `-mm={gc,rc,none}` must be coherent across JIT, executable, DLL and shared-import paths, all of which read `CompileOptions` independently. | + +## 7. Performance is not the argument + +The honest case for RC is **determinism and memory footprint**, not throughput. A naive +implementation puts a retain/release on every array fat-pointer copy and every string +assignment — and strings are the highest-churn allocation in the compiler, since every +concat and every number-to-string allocates. Non-atomic counts are cheap, but +`ENABLE_ASYNC` is on, so any value crossing a coroutine suspension point needs atomics or a +thread-confinement proof that does not exist today. + +## 8. Cost by tier + +Tiers A and B are mode-neutral and improve the GC default immediately. C and D are the RC +option proper. + +| Tier | Scope | Size | Effect | +| --- | --- | --- | --- | +| A | Escape analysis: promote non-escaping `MemoryAlloc` to `alloca` | small | Pure win, both modes | +| H | Uniform object header in both modes (§4a) | medium | Mode-neutral; unblocks everything below | +| B | Extend `mlirGenDisposable` to free provably scope-bound temporaries | small | Pure win | +| C | `-mm=rc` supporting `string` only, other types still GC-allocated | medium | Shippable increment | +| D | `-mm=rc` across the heap | multi-month | Leaks cycles, by documented design | +| D+ | D plus a cycle collector | D plus a second collector | Parity with GC | + +Tier A is worth more under RC than under GC: every heap object elided is retain/release +traffic elided, not merely collector pressure. + +## 9. Recommended order + +The ordering point: **steps 1-4 are useful on their own, land under GC where mistakes are +harmless, and commit to nothing.** Step 5 is the commitment. + +1. **Settle the ABI question (§4)** and write it down. Nothing else should start first. +2. **Uniform object header in both modes**, GC still running. A wrong count is inert here, + which makes the widest-blast-radius change the safest to land and the easiest to test. + Add the memory-model marker to `declExports` at the same time. **Split this in two — see + §9.1, the halves are not equally hard.** + +### 9.1 The header has two allocation paths, and only one is easy + +This is the detail that decides how big step 2 is. + +> **Status: path 1 landed 2026-09-02 and the full release suite passes.** The header is +> reserved in `_MemoryAlloc` / `_MemoryRealloc` / `_MemoryFree` and the word is never read. +> All three helpers were genuinely exercised: 19 tests drive array `push`/`splice`/`unshift` +> through realloc, 37 exercise string allocation and `SetStringLength`, and 9 use `delete` +> (including `00new_delete.ts`) through free. **The provenance worry did not materialise** — +> nothing reaches realloc or free holding a pointer that did not come from the allocator, so +> the base adjustment is safe in practice, not merely in principle. The 72 cross-module tests +> pass, which is the result that matters most for §4. +> +> Still unvalidated by this run: the WASM allocator path (`ts_malloc`/`ts_realloc`/`ts_free`), +> which is built and tested separately. +> +> **Status: path 2 resolved 2026-09-03 by removing it, not by shifting it.** Investigating the +> descriptor shift showed the bitmap it would shift was never correct, so `ENABLE_TYPED_GC` is +> now `false` and class instances take the generic path like everything else. See §9.2. + +### 9.2 The typed path was retired rather than adapted + +The plan in §9.1 was to shift every descriptor bit by the header size. Reading +`mlirGenClassTypeBitmap` (`MLIRGenClasses.cpp:1322`) first showed there was nothing sound to +shift. Three defects, each confirmed against the code: + +1. **Shift direction inverted.** Line 1427 passes `GreaterThanGreaterThanToken`, which maps to + `rightShift` (`MLIRGenImpl.h:4996`), sitting directly under a comment reading + `// 1 << index_mod`. `1 >> bitIndex` is zero for every bit position but zero, so no bit + above position zero could ever be set. +2. **Wrong array index.** Line 1412 indexes the bitmap with `calcIndex`, the field's word index + *within the object*, where it needs `calcIndex / bitsPerWord`. The array holds only + `ceil(N/64)` elements, so any class with pointer fields past word zero also read and wrote + past the end of the stack allocation. +3. **Never zeroed.** `AllocaOpLowering` emits a bare alloca under an explicit + `// TODO: call MemSet` (`LowerToLLVM.cpp:2223`), and the generator only ORs bits in. The + descriptor was therefore derived from uninitialized stack memory. + +Net effect: `GC_make_descriptor` received a garbage bitmap, and any pointer field whose bit +read as zero went untraced, so a reachable object could be collected. Latent because short +tests seldom trigger a collection cycle. + +Since the precision was fictitious, the cheaper and safer resolution was to stop using the +typed path rather than repair and then shift it. Class instances now lower through +`NewOp` → `NewOpLowering` → `MemoryAlloc` (`LowerToLLVM.cpp:2261`), which means they are +conservatively scanned — what they effectively got anyway — and they pick up the block header +uniformly, which is what §4 needed. The `#else` branches for this already existed; only the +`Config.h` flag changed. + +This closes the ABI question: **every heap allocation now carries the header.** The bitmap +generator's three defects remain in the tree, unused and documented, and are worth their own +fix if precise class scanning is ever wanted back. + +**Path 1 — the generic helpers (easy).** `_MemoryAlloc`, `_MemoryRealloc` and `_MemoryFree` +all live in `LLVMCodeHelperBase.h:253/300/330`. Eleven of the twelve allocation sites route +through them, and so does the single `free` site (`DeleteOpLowering`, `LowerToLLVM.cpp:3022`, +the `delete` operator). Prepending a word means: allocate `size + H` and return `ptr + H`; +pass `ptr - H` on realloc and free. **Everything else is unaffected**, because every other +consumer — `strlen`, `strcpy`, `strcat`, `strcmp`, GEPs, the array `{ptr,len}` pair — operates +on the payload pointer and never sees the block base. Under GC the word is never read, so the +change is inert and the existing suite is a complete oracle. This is one file and three +functions. + +**Path 2 — the typed-GC class path (the hard half).** `GCNewExplicitlyTypedOpLowering` +(`LowerToLLVM.cpp:5879`) does **not** go through those helpers. It calls +`GC_malloc_explicitly_typed(sizeof(storageType), typeDescr)` directly. And the descriptor +collides with a header: `mlirGenClassTypeBitmap` computes each bit index as *field address off +a null base, divided by word size* (`MLIRGenClasses.cpp:1400-1420`), so bit positions are +**object-base-relative**. Prepend a header and the object base no longer coincides with the +block base Boehm scans, so every bit in the descriptor is off by `H/wordsize`. Boehm then +traces the wrong words — silent false retention or, worse, premature collection of live +objects, under the *default* configuration. + +So path 2 requires shifting every bitmap bit by the header size and reserving the leading +word as non-pointer, and it perturbs machinery that is live and load-bearing today. Land +path 1 first and alone; treat path 2 as its own change with its own verification. +3. **Real type ids in `any`/union boxes**, replacing the type-name string (§3.4). + Independently useful — `any` comparison already pays for stringly-typed tags. + **Done 2026-09-03, see §9.3.** +4. **Generate per-type release routines** from the existing bitmap machinery, initially + unreferenced and verifiable in isolation. **Done 2026-09-03, see §9.4** — built fresh + rather than from the bitmap machinery, which §9.2 had already retired as unsound. +4a. **Give static string literals the block header, with an immortal marker.** Inserted by + §9.4's finding: a `string` field can hold a pointer into a read-only global, so releasing + strings is impossible until heap and static strings are distinguishable. + **Done 2026-09-03, see §9.5** — which also closed a second hole, `typeof` results pointing + into descriptors. +4b. **`-mm={gc,rc,none}`, and maintain the count.** The flag step 5 hangs off, plus + initialising the header at allocation and turning §9.4 destroy routines into real + reference drops. Still inert. **Done 2026-09-03, see §9.6.** +4c. **Memory-model marker in `declExports`.** §4's last outstanding piece, and a prerequisite + for marking foreign objects immortal. **Done 2026-09-03, see §9.7.** +4d. **`WeakRef` representation.** Settled on paper before any code, because the header + layout it implies is ABI. **Designed 2026-09-03, see §9.8; not implemented.** +4e. **`ts.Retain` / `ts.Release` in the dialect**, with retain routines to match the release + ones, so that ownership can be *stated* before deciding where. Still inert — nothing emits + them. **Done 2026-09-03, see §9.10.** +4f. **`using` disposes on the unwind path, for the case ownership tracking will lean on.** + A prerequisite for step 5, not step 5 itself: confirms the scope-exit machinery ownership + insertion will reuse actually runs on `throw`, for at least one real shape. Narrowly scoped + after surfacing several independent pre-existing gaps in the same machinery. **Done + 2026-09-03, see §9.11.** +5. **Ownership tracking in MLIRGen behind `-mm=rc`**, checked by a verifier that flags any + owned value without a matching release on every path, unwind paths included. **Verifier done + 2026-09-04, see §9.18.** *Point of + no return* — and the first step where a mistake is not inert: a missing retain frees live + memory, an extra one leaks. Narrowed by §9.10: the mistake can only reach `-mm=rc`. +5a. **Locals own what they hold.** The first slice of step 5 and the one that builds the + mechanism the rest reuses. Deliberately balanced by construction, so it cannot + over-release. **Done 2026-09-03, see §9.12.** +5b. **Owned storage is hoisted out of the `TryOp`, and the unwind leg releases.** **Done + 2026-09-04, see §9.15.** +5c. **Fields own what they hold.** The first insertion point beyond locals, and the first the + verifier guarded rather than followed. **Done 2026-09-04, see §9.19.** +5d. **Elements own what they hold.** `arr[i] = x`, the direct sibling of 5c. Exposed the first + *over*-release: an array literal stores its elements without retaining them. + **Done 2026-09-04, see §9.20.** +5e. **Literal construction retains what it captures.** Array literals and boxed object literals, + which fill an owning block in one go rather than through an assignment. Taken ahead of the + rest because it turned out not to be latent at all — two holders and two overwrites freed a + live value on the compiler as it stood. **Done 2026-09-04, see §9.21.** The spread form + (`[...xs, y]`) goes through `ts.ArrayPush` and waits for 5f; the unboxed object literal is the + inline-record case and waits for 5g. +5f. **The array-mutating ops.** `push`/`unshift`/`splice` now take a reference; `pop`/`shift` + correctly need none — the block transfers its reference to the result rather than releasing + it, which is the existing "+1 nobody consumed" convention. Also closes 5e's spread-literal + hole, since `[...xs]` is built out of `push`. **Done 2026-09-04, see §9.22.** What `splice` + *deletes* still drops references without releasing them (a leak, and the first item needing + emission from `LowerToLLVM` rather than MLIRGen). +5g. **Inline records** — an assignment through a field of a record held inline now retains and + releases, conditionally on the storage under it owning. Arguments turned out to need nothing + (a parameter's slot is not owned, so they are already borrowed at +0) and returns likewise + (the scope-exit release balances the declaration's retain, and the caller receives the birth + reference). 5e's unboxed object literal was never broken either — construction balances + through the owned local's `RetainSlot`. **Done 2026-09-04, see §9.23.** +5h. **Remove 5a's slack**: consume a freshly allocated value's birth reference. The point where a + mistake stops being an inert leak, and where every test written since 5a gains teeth. + **First half done 2026-09-04, see §9.24**: allocations are born unowned, and a `return` + retains its value so the scope exit cannot free it on the way out — which makes the + convention uniform at *every function returns +1*. A real removal for arrays, strings and + boxed object literals; still neutral for class instances, because `new C()` is a call to + `C..new` and that return retain hands back the same +1 the birth reference did (verified by + the release-before-retain swap, not assumed). +5i. **Consume the +1 at the receiving sites** — all four (declaration, store, literal capture, + push) now take an already-owned value over instead of retaining it again. **Done 2026-09-04, + see §9.25.** Only `new C()` is marked as producing one, at the site that knows the callee is + the generated `C..new`; nothing is inferred from an operation being a call, because a runtime + helper or a pre-convention import returns a heap value with no retain behind it and consuming + one of those frees live memory. **`let x = new C()` is now genuinely freed, and the + release-before-retain swap finally fails three of the ownership tests** — the experiment + §9.19 asked to be re-run once the slack went. +5j. **`pop`/`shift` transfers consumed** — the compiler's own operations, so nothing to + classify. **Done 2026-09-04, see §9.26.** +5k. **An ordinary call's result** (`let y = f()`) — done via a module pass after MLIRGen that + inspects each function's returns instead of predicting them. **Done 2026-09-04, see §9.27.** + 469 call sites marked across the suite. +5m. **Retain the value a return actually returns.** The retain landed on the value the return + statement evaluated, but `mlirGenReturnValue` then cast it to the declared return type, so a + return needing a cast retained the wrong value. **Done 2026-09-04, see §9.31**, where it had + to be: with a cast that allocates - a literal returned as an interface - the block the caller + receives got no reference at all, so this was not only the leak it looked like. +5l. **Discarded temporaries** — `f();`, and every call result used as an argument without being + bound to anything, which is what expression-shaped code is made of. **Done 2026-09-04, see + §9.30**: consumption is recorded explicitly now, and what nothing consumed is released at the + end of the block that produced it. Closing it also closed a §9.27 gap that kept it from firing + at all — `return new C()` forwards a reference rather than retaining one, so those functions + were never classified as returning owned. `raytrace.ts` 129.5 MB → 79.3 MB, below `none` for + the first time; the nested-call shape on its own is flat. +5n. **An interface owns what it was made from.** **Done 2026-09-04, see §9.31.** The type + carries only a name, so the value now carries the runtime type tag of its `this` and releases + through the concrete type's own routines, exactly as an `any` box does. Both interface shapes + are flat: a boxed literal bound in a loop, and one passed as an argument and dropped. Making + an interface an owner turned two dormant omissions into live over-releases - a boxed literal + that never retained its fields, and a pushed owned result never marked consumed - so + `raytrace`'s figure went **up**, 79.3 MB to 114.2: part of §9.30's number was memory freed + while still referenced. +5o. **Classify an instance method's callee.** **DONE - by §9.46, §9.53 and §9.54, and measured + closed in §9.64.** The item was written on 2026-09-04 and its text describes the compiler of + that day; each of its three claims has since become false, and none of them was re-read. + `raytrace` now refuses **0 of its 80** owning calls, where the item says virtual dispatch + costs it 2.6%. A precise override set - the thing the item calls the proper fix - would change + **two** decisions across all 261 corpus files, both cross-module. And `new C(...)` through a + constructor interface, which the item says leaks one instance every time, measures 4.1 MB + against `none`'s 13.4, because §9.53's interface half covers the shape §9.46 could not. + What survives is cross-module only and is re-filed as 5al, because it is an ABI question + rather than a classification one. + The half of the original investigation that still reads true: `calleeNameOf` looks through the + bound-function chains the dialect's own canonicalizer already resolves, and that is worth + almost nothing on its own, because every non-virtual method reference in `raytrace` is a + constructor. `private` does not make a call single-target here either - this compiler accepts a + subclass redeclaring a private method and dispatches to the override, where TypeScript rejects + the program. +5p. **A closure owns its capture box.** **Done 2026-09-04, see §9.33.** A bound or hybrid + function value carries the tag of its `this` beside the pointer, as an interface does, and + only a closure over captured variables is marked as owning it - a bound method must not take + ownership of its receiver. Measuring it turned up the larger of the two: **§9.30's releases + were being placed after `ts.ReturnVal`, which is not a terminator, and the affine lowering was + dropping them** - 36 of `raytrace`'s 47. `raytrace` 113.8 -> 103.4 MB. +5q. **A captured variable's cell is owned, not borrowed.** **Done 2026-09-04, see §9.34.** A + variable a closure captures by reference gets a heap block of its own - a *cell* - and it had + no owner at all: the frame released the *value* at scope exit while the box still pointed at + the cell, and the cell itself was never freed. The cell now has owners - the frame that + declared the variable, and each box that captured it - and the box has routines of its own + that give the cells back. Two further over-releases fell out of writing the cases: a `const` + captured by value was not retained by the box, and assigning to a captured variable *from + inside the closure* stored a value nothing had taken, which §9.30 then freed as a discarded + temporary. `raytrace` 103.4 -> **2.6 MB**, below `gc`'s 4.2. +5r. **A cell is given back whether or not the frame owns what is in it.** **Done 2026-09-05, see + §9.35.** The frame released a cell only where §9.34's list already held the slot, and that + list is the list of locals whose *value* the frame owns - so a captured parameter, whose + argument belongs to the caller, and a captured local of a type that owns nothing, such as a + `number`, both leaked the cell. Every local and every parameter is now listed, and scope exit + asks each what it turned out to be; and a cell takes a reference to the value it is + initialised with unless the frame already took one, which is what makes it safe for the + cell's release to release its contents whoever put them there. Closure over a parameter at + `-O0`: **22.6 -> 3.7 MB**, against `gc`'s 2.6 and `none`'s 22.0; closure over a `number` + local, 51.7 -> 3.7 against `none`'s 113.0. +5s. **Boxing into `any` takes no reference to what the box then owns.** **Done 2026-09-05, see + §9.36.** An `any` releases its payload through the type tag when the box dies, and nothing had + taken that reference: the payload was copied in and, where it arrived carrying one of its own + - a closure, whose reference is the box of captured variables it was built over - §9.30 gave + that reference back at the end of the block and left the `any` pointing at freed memory. This + was `22lambdas.ts`'s intermittent failure under `rc`, one run in four, which is what a + use-after-free looks like from outside. The boxing cast now consumes or retains like every + other owning receiver. A second bug fell out of the corpus sweep and belongs to 5r: a captured + declaration with **no initializer** has a cell holding whatever the allocator last left there, + and scope exit releases it - cells are now zeroed at birth, as owned locals already were. +5t. **A freshly built string carries no reference for its receiver.** **Done 2026-09-05, see + §9.37.** Printing a number into a string allocates one, and so does concatenating; neither + said so, so a receiver added a reference of its own - balanced, and it worked - while an + intermediate no receiver ever took was left with none and leaked. `"s" + k` leaks twice over: + the conversion's result feeds the concatenation and is then forgotten. Both now hand back a + reference the way `new` and a call do. `let s = "s" + k` at `-O0`, 500k iterations: + **34.5 -> 3.7 MB**, below `gc`'s 4.1; and the `any`-boxing benchmark, whose remaining leak + this turned out to be, 39.9 -> 3.7. A second bug came out of the same measurements and is + older than any of this: **growing an array does not zero the slots it exposes**, and the + store into one releases what the slot held - which is `Array.map` in the default library, and + was `arrS.map(e => e + "_")` crashing 6 runs in 20 under `rc`. +5u. **Read the `-mm=rc` corpus sweep through.** **Done 2026-09-05, see §9.38.** Of the 117 + non-zero exits, **101 fail identically under `gc`** - 79 are one half of a two-file + `export_*`/`import_*` test that cannot be JITed alone, 20 are the default library colliding + with a test written for `--no-default-lib`, 2 fail under both models. The sweep had also + never run the configuration the suite runs (`--opt --opt_level=3 --no-default-lib`), which is + what manufactured those 20 and hid 13 real ones. Sweeping both configurations under both + models leaves **29 files that fault under `rc` and pass under `gc`**, of which **exactly one + is a file ctest ever runs under `rc`**. **18 of the 29 fault in only some of the four + `{-O0,-O3} x {default library, none}` configurations**, which is what a use-after-free looks + like from outside: the columns vary the heap layout, not the ownership. Three reductions came + out of it, and they are the next three slices. +5v. **A generator's unwritten local made the whole caller undefined.** **Done 2026-09-05, see + §9.39.** It was not the nesting: `function* g() { const a = [1]; yield a[0]; }` faults on its + own. A generator's locals are fields of a state object that is retained before the body has + ever run, and an unwritten field lowered to `undef`, so the retain read a refcount through it + which is undefined behaviour, and at `-O2` and above it folds `main` to one `unreachable`. An + unspecified field of an owning type now lowers to zero under `rc`. Closed 9 files across the + two swept configurations with nothing newly broken (rc-only 16 -> 14 bare, 22 -> 15 under the + suite's flags), including three with no generator in them; the `-O3`-with-default-library cell + was swept for the first time and is 16 rc-only. Across the three configurations, **29 -> 22**. +5w. **What a `for...of` hands the loop variable.** **Done 2026-09-05, see §9.40.** Three fixes, + the first two the same mistake twice. **A literal array had no block header** - a string + literal has carried the immortal one since §9.5, an array literal carried nothing, so a + retain read and a release wrote the word in front of a read-only global. **And a header is + only a header one word in front**: three `i32` want sixteen-byte alignment, so the unpacked + block struct padded and put the header two words back, which is why `[1]` and `[2,3]` were + fine and `[4,5,6]` was not. Then, from `-O1` up, **the caller retains an iterator's result + before it looks at `done`**, and the final result's value is `undefined` coerced to the + element type, which was `undef` - §9.39's bug as a runtime phi rather than a constant. + `undefined` as an array is now `{null, 0}`, and any `mlir_ts::UndefOp` of an owning type is + null. Closed 5w's whole predicted cluster plus five files nobody had attributed to it, with + nothing newly broken: bare 115 -> 106, suite flags 102 -> 97, `-O3`-with-library 117 -> 107, + and the rc-only set across the three configurations **22 -> 12**. +5x. **A coroutine frame was allocated on one heap and freed on another.** **Done 2026-09-05, see + §9.41.** Not a double free and never reference counting's: `none` frees nothing and failed + too. The frame is asked for with `aligned_alloc` and released with plain `free`, which is the + C11 pairing; MSVC has no `aligned_alloc`, so the runtime supplied one built on + `_aligned_malloc`, whose memory only `_aligned_free` may release. `gc` never showed it because + GCPass rewrites the pair to GC_memalign/GC_free. The shim now serves the request from + `malloc`, whose 16-byte alignment beats the frame's 8. The same symbol was missing from the + static library, so **`-mm=rc` and `-mm=none` could not link an executable that awaited + anything** - unnoticed because ctest's AOT tier runs the default model only. rc-only across + the three configurations **12 -> 11** (10 real), and **`none` now has no failure `gc` does not + share**. +5y. **The corpus under the models that are not the default.** **Done 2026-09-05, see §9.42.** + `rc` ran 42 files of 478 and `none` ran 24, and neither ran one of them ahead of time, which + is why every fault since §9.38 was found by an out-of-tree sweep and why 5x's was invisible + twice over. Every single-file test the default model runs is now run again in the same tier + under both other models, and so is every shared-component pair - as a `foreach` over the + entries that already exist, so the two lists cannot drift apart. 945 -> 2,585 tests, 18.8 s + -> about 50 s at `-j 12`. The first run named ten files (5ae), and two faults in the harness + itself: a failing `assert` under `--emit=jit` was a modal message box and therefore a hang + rather than a failure, and the shared-component runner dropped the space between `-mm=` and + `--gctors-as-method`. +5z. **DONE, §9.52 - and its own diagnosis was right.** A generator that takes a parameter leaked + its capture box, because `.captured` is a `ref>` and a reference into storage owns + nothing anywhere else in the compiler, so `releaseFields` skipped the one field of the state + object that owns memory. `releaseFields` now routes that shape through the capture-box release + routine closures already had, and the object takes the one reference that pays for it. + `function* gen(n) { yield n; }` at 500k iterations went from 33 MB to 3.3 MB, below `gc`'s 3.7. + The shapes 5z originally named - a parameter *and* a local - had already been closed by §9.50 + without being measured. Every number it quoted was taken in the JIT and should be read as + gone; see §9.52 for the harness that replaced that method. +5aa. **DONE, §9.58 - it does not reproduce, and `rc` wins this one.** Measured on the harness that + replaced the method the 15.6 MB came from: iterating a literal array is **flat at 0.7 MB** from + 300k to 3M iterations, against `gc`'s 2.8 and a `none` that climbs to 482. Numbers, strings, + both. The old figure is withdrawn with the rest of the JIT ones (§9.52). +5ab. **DONE, §9.56 - and it was never about arguments.** What decided it was the awaited + function's **result type**: the value travelled through `!async.value`, and MLIR's + async-to-LLVM conversion runs before this compiler's own types are lowered, so a payload of + any TypeScript type had nothing to convert it. `i32`, `i64` and `f32` compiled because they + are builtin MLIR types; `number`, `string`, `boolean`, a class and an array did not. + `await twice(3)` compiled all along - `withDefault()` returns an `i32`. The result now travels + through a slot in the awaiting function and the async value carries only a token. Under `rc` + that slot has to own what it holds, or the awaited region releases the value as it ends. +5ac. **DONE, §9.57 - the collector was allocating without a lock.** A coroutine is resumed on the + async runtime's thread pool, and under `gc` it frees its own frame there. Boehm leaves + `GC_need_to_lock` FALSE until something tells it there is more than one thread, so a worker and + the awaiting thread walked the same free lists with no lock at all. `GC_allow_register_threads` + sets that flag; the workers now also register themselves so a collection can suspend them and + scan their stacks. 200k awaits went from about 1 failure in 4 to none in 32, and 400k - twice + the worst case ever measured - is clean. `rc` and `none` were never affected: their frames go + to the CRT heap. +5ad. **DONE, §9.51 - and it was not an `rc` bug.** A `main` returning nothing lowered to + `void @main()`, and the C runtime reads an exit code out of the return register whatever the + signature says. Zero under `gc` and `none` by luck, 1 under `rc`, where the last thing `main` + does is give back a reference. The entry point is now `i32 @main()` returning 0, and the test + runner checks the exit code of what it ran - which it never had. +5ae. **DONE - no corpus file faults under any model.** What §9.42 bought, and it is empty: + `TSLANG_CORPUS_BROKEN_*` has no entries, and the suite runs 2,617 tests with nothing disabled. + §9.50 took the last one, `00spread.ts`. §9.49 took `nbody.ts` and, with + it, the two that had been failing about one run in six - `13actions.ts` and + `44toplevelcode.ts` - because a global never took a reference to what was stored into it, and + how far a program got before that showed was a matter of what the allocator handed back next. + §9.48 took `00mixed_type_ops.ts` + off it (a boxing cast reported no memory effects, so CSE merged two boxes over one constant + into a block that was then freed twice). §9.43 took `25lamdacapture.ts` + and `raytrace.ts` off it (a nested capture never retained the cell it inherited), §9.44 took + `00generator6.ts` and `00safe_cast_field_access.ts` (a union that holds nothing yet has a + null tag, and both directions read through it), §9.45 took `00class_static.ts` (`delete` + dropped a reference without telling the end-of-block release to stop), and §9.46 took + `01class_new.ts` (the method the compiler synthesises for a constructor interface's `new` + slot has no `return` statement, and so performed none of what a return does). The lists in + `test/tester/CMakeLists.txt` are kept empty rather than deleted: they are how the next such + fault gets written down in the build while it is being worked on. +5af. **DONE, §9.60 - `raytrace` costs `rc` 6.2 MB against `gc`'s 2.8.** `none` is 117, so + reference counting now reclaims **95%** of what the program leaks without it, up from 62%. The + whole of the remainder was one shape: a value nothing receives is released at the end of the + block that produced it, and a `return` written inside an `if` leaves from a nested region and + never reaches that release. `raytrace` builds a ray for each shadow test in a function that + returns early when the light is blocked, so the ray came back only on the path that fell + through. Everything this item previously suspected was measured and cleared first: not the + closure, not the `Intersection` object literal through an interface, not the rays as such, not + the vector arithmetic (§9.59, §9.60). + +5ag. **DONE, §9.50 - and the second half turned out to be simpler than the diagnosis below.** + The state object does not need ownership of its capture box: what the box loses is the *value* + of a by-value capture, which the box already releases and which nothing had retained, because + `mlirGenResolveCapturedVars` chose between a cell retain and a value retain by asking whether a + reference could be had rather than what the box would store. The diagnosis below stands + otherwise, and its ordering was right. What follows is how it read before the fix. + **A generator's state object releases what it never took, and loses its capture box when it + outlives its maker.** Two halves of one bug, and the order matters: the second has to be + fixed first. A generator's locals cannot live in its frame - the state machine has to resume - + so each becomes a field of a heap state object, and that object's release routine is + generated from its type and gives back every field that owns memory. The frame declines + ownership of those locals for exactly that reason (`localTakesOwnership`, + `trackPossibleCell` both exclude `allocateInContextThis`), and **nothing takes the reference + the object will later give back**. `[1, 2, 3].map(f)` compiles to a synthesised + `function*` running `for (const v of .src_array) yield f(v)`, so the `for...of` lowering + stores the array into a generator local, and the capture box and the state object each free + it. Invisible until `-O3`, where the optimiser proves the two pointers equal: +5ah. **DONE, §9.48 - and the diagnosis below is wrong, which is why it is kept.** The optimiser + was not the cause. The second box is gone before LLVM sees the module: generic CSE merges two + structurally identical boxing casts on our own dialect, because `CastOp::getEffects` reported + an allocation for `ConstArrayType` to `ArrayType` and not for a cast to `any`. Reporting it + is the fix; neither way out proposed below was taken, and the preferred one - interning + constant boxes - would have hidden every case that exists rather than fixing any. What + follows is what the `-O1`/`-O3` counts looked like from the wrong end. + **The optimiser removes an `any` box's allocation and keeps its `free`.** `00mixed_type_ops.ts`, + and five lines are the whole reduction: + + ```typescript + function main() { + let a: any = "abc"; + a = true; + a = false; + a = true; + } + ``` + + MLIRGen is right and so is the LLVM dialect: four boxes, each allocated by `castToAny`, each + retained once by the assignment and released once by the slot - `--emit=mlir-llvm` shows four + `llvm.call @malloc` and four `tsrel_` in `main`. At `-O1` LLVM has three `malloc`s and four + `free`s; at `-O3`, one `malloc` and two `free`s of it. **The allocations go and the frees + stay.** A box looks to LLVM exactly like a removable allocation - a `malloc` whose pointer is + stored to a slot SROA has promoted away, read back, and freed - and where two boxes hold the + same constant the analysis conflates them, so what is left frees one block twice. + + It is `rc`-only for a plain reason: `GCPass` rewrites `malloc` to `GC_malloc` and drops the + `free` entirely, so under `gc` there is nothing to double, and under `none` there are no frees + at all. Only `-mm=rc` hands LLVM a matched `malloc`/`free` pair to reason about, and step 6 is + what started doing that. + + Two ways out, and the second is better. **Stop LLVM reasoning about these blocks** - allocate + and free through runtime entry points that carry no `alloc-family` attribute, so a refcounted + block is never a candidate for allocation removal. It is a small change and it costs the + optimiser every legitimate elision of a short-lived box, which is not a small price for a + memory model whose case rests on not paying for what it does not use. Or **give a constant its + box once**: `castToAny` of a compile-time constant can address a module-level global carrying + the immortal header (4a's trick for a static string, 5w's for a literal array) instead of + allocating. Then there is genuinely one box, nothing to free, nothing to conflate, and four + allocations disappear from this reduction rather than three. The general case - a box over a + runtime value - still hands LLVM a `malloc`/`free` pair, so this does not close the class; it + closes what the corpus actually hits. + + ```llvm + %0 = tail call ptr @malloc(i64 20) ; [1, 2, 3] copied to the heap + ... + tail call void @free(ptr nonnull %0) + tail call void @free(ptr nonnull %0) + ``` + + That is all of `00spread.ts`, and the retain that fixes it - in `createLocalVariable`, where + the `allocateInContextThis` store is emitted - fixes the whole file and every reduction of + it. **It also breaks `00extension_cond_access.ts`, and correctly.** A generator returned + from the function that built it keeps a `.captured` pointer into a capture box that function + owned and released on the way out, so the cells behind it are freed while the generator is + still reading them. Today that is a silent read of a block nobody has reused; a retain makes + it a *write* of a refcount into a freed block, which corrupts the free list at once. The + whole reduction: + + ```typescript + function f(names: string[]) { return names.filter(x => x); } + function main() { for (const s of f(["asd", "asd1"])) print(s); } + ``` + + So: give the state object ownership of its capture box first - the same question §9.33 + answered for a closure, where a bound function carries the tag of its `this` - and only then + let a generator local take the reference its object gives back. `mlirGenResolveCapturedVars` + is where a box takes its cells, and its final `else` (a ref that is neither a `VariableOp`, + a `ParamOp`, nor a captured cell slot) is where the factory's re-capture from an incoming + capture tuple falls through, retaining nothing. Test written and not committed: + `00owned_generator_locals.ts`, seven cases, six of which the local retain alone turns green. +5ai. **DONE, §9.49 - a global never took a reference to what was stored into it.** `nbody.ts`, + `13actions.ts` and `44toplevelcode.ts`, all three the same bug. Ownership skipped globals + because a global outlives every scope and so has no scope exit to release from, and skipping + the release dropped the retain with it - a store into a global neither took a reference nor + gave one back, so the value was released at the end of the function that built it and the + global addressed a freed block. A global is a root: it holds a reference for as long as the + program runs, and nothing gives the last one back. New `isOwnedGlobalSlot`. +5aj. **DONE, §9.60 - a null value carried past the check that was supposed to stop it.** The + return statement reported "No return value" and then went on to cast and retain it. A discovery + pass reaching there is ordinary rather than an error - a return expression can depend on + something not registered yet - so the report was conditional and the failure was not; it needed + to be the other way round. Reading a null `mlir::Value`'s type faults, which is why there was no + diagnostic. Turning `raytrace.ts`'s `Intersection` into a class is what reached it. +5an. **DONE, §9.70 - `+` uses the same promotion as every other arithmetic operator, once both + operands are unambiguously numeric.** Filed by §9.67, and not an ownership bug. §9.67 had seen + one of its three shapes: a control binary showed `+` narrowing its right operand to the left + one's type in *any* direction, so `true + 2.5` read 2 and `i8(100) + i32(1000)` read 76, as + well as `2 + 3.5` reading 5. Strings, `any` and unions keep the left-preferring rule that + string concatenation depends on. + +5am. **DONE, §9.68 - one flag, after §9.66 estimated it as an allocator rewrite.** + `SectionMemoryManager(nullptr, /*ReserveAlloc=*/true)` reserves one contiguous block laid out + code-first, so the lowest section is always code and the RVA-0 sentinel has nowhere harmful to + land. Suite 2,667/2,667 with nothing disabled. + Filed by §9.65, diagnosed in §9.66. RTDyld's image base is the lowest section load address, so + a descriptor allocated there has RVA 0 - and `dispType == 0` is the MSVC encoding's + `catch(...)`. The clause still catches, so the only visible symptom is that the catch object + is never copied and the variable reads uninitialised memory. Not an ownership bug and not + `rc`-specific: all three models, both opt levels, every payload type, and correct ahead of + time in every case tried, where RVA 0 is a PE's DOS header and never a datum. Supersedes + §9.29's "reads 0" and its "depends on what else the module throws". **The image-base shim of + §9.13, which this item first accused, is measured working** - the throw-side and handler-side + bases match. Fix: place code below read-only data via `reserveAllocationSpace`, so the RVA-0 + sentinel can only fall on code, where nothing reads 0 as "none". + +5al. **DONE, §9.71 - an imported function's returned reference is taken over, and the soundness + the item stalled on was already provided.** Filed by §9.64 as a precision refinement worth a + couple of calls; it was worth all of it. `rc` and `none` agreed *to the decimal* on a + two-module program (18.0 MB each, `gc` 5.7), so reference counting reclaimed nothing at all + across a boundary. Now 3.8 MB - better than `gc`. The classification was easy as predicted + (`export` distinguishes an imported tslang function from a `declare`d C one). The soundness + was not a policy question after all: consumption *removes a receiver's retain* rather than + adding a release, so a foreign block's count goes to -1, which is `HEAP_BLOCK_IMMORTAL`, and + it leaks rather than being freed twice - §9.7's agreed answer, delivered by §9.24's + born-at-zero design. Checked across all nine exporter/importer model combinations. The + default-lib case is unchanged and still leaks, for the same reason it always did. + +5ao. **DONE, §9.76 - the executable and the shared library each linked a collector of their + own.** Originally filed as: Found by §9.71's new test and pre-existing - it fails with 5al's fix reverted + too. Only shared + `gc` + AOT; shared `rc`, shared `none`, static `gc` and shared `gc` + through the JIT all pass. Points at Boehm not tracing the importing module's roots into a + dynamically linked module's heap, which is the same family as the JIT-globals problem. + `test-compile-shared-export-import-owned-returns` is registered and DISABLED. + +5ak. **DONE, §9.61 - a jump is asked whether it leaves the block, not where it is written.** The + release at the end of a loop body is skipped by an iteration that ends in `break` or + `continue`, so that iteration's discarded temporaries were lost. Releasing at every jump would + have been wrong in the other direction - a jump caught by a loop BELOW the block comes back and + runs the end-of-block release as well - so the pass now walks from the jump out to the block + and asks what catches it, which is also what makes a labelled `break` out of two loops come out + right. 13.9 MB against `none`'s 53.2, down to 0.6. + +6. **Flip the allocator under the flag.** **Done 2026-09-04, see §9.28.** `needsGCRuntime()` now + names only `gc`; `rc` allocates from `malloc`, frees through `free` and links no libgc, so a + memory measurement under it finally means something — a million-iteration allocation loop stays + flat at 3.8 MB where `none` reaches 172.8 MB. Flushed out a Win64 crash that was never RC's: + allocating inside a catch funclet, latent under `-mm=none` since long before this work. + +**Scope the first shipping mode narrowly.** Two candidates, and they are compatible: + +- **`string` only (Tier C).** Strings are leaves — a string never points to another heap + object, so release is a single free with no recursive traversal and **no cycle is + representable**. Strings are also the highest allocation-rate type. Highest benefit, zero + cycle risk, bounded blast radius. Step 4a (§9.5) cleared the static-string blocker; what is + left before this scope is reachable is maintaining the count itself. +- **WASM target.** The strongest driver for RC existing at all. WASM is the one environment + where conservative native-stack scanning is unavailable, which is the assumption Boehm + rests on (`docs/llvm-gc-integration.md`), and the compiler already forks its allocation + path there (`ts_malloc`/`ts_realloc`/`ts_free`, `LLVMCodeHelperBase.h:265/312/340`, patched + back by `MemAllocFixPass.cpp`). Scoping the first RC mode to WASM rides a split that + already exists. + +The other drivers that would justify Tier D: hard real-time latency budgets, and shipping +without a runtime dependency on libgc. + +### 9.3 Step 3: the tag now points into a per-type descriptor + +Landed 2026-09-03, full release suite green (829/829, 106 of them cross-module). + +The obstacle was that the tag is not merely *a* string, it is the **`typeof` result itself**: +`GetTypeInfoFromUnionOp` returns it straight to `typeof`, `MLIRGenImpl.h:3895` `strcmp`s it +against `"class"` to implement `instanceof` over `any`, and the generated union operator +helpers compare `typeof(r) == "class"` in source text. Anything that stops the tag being a +readable `char*` breaks all three. + +So the tag stays a `char*` and the record moves in front of it. Each distinct type gets one +static global `{ { i32 kind, i32 reserved, ptr release }, [N x i8] name }`, and the tag is +the address of `name`. Every existing consumer keeps reading a NUL-terminated type name and +is untouched; anything wanting the record takes `tag - sizeof(record)`, which the emitted IR +constant-folds to `getelementptr i8, ptr @td_..., i64 -16`. The trailing name is a byte +array, so nothing is padded in front of it and that offset equals the record size on every +target. This is the same header-in-front-of-payload shape as step 2, deliberately. + +Three consequences worth recording: + +- **The descriptor is keyed by the concrete type, not by the name.** Two classes both report + `"class"` and now get two records — which is the entire point, since step 4 needs somewhere + per-class to hang a release routine, and the name erases exactly that distinction. + `typeOfBaseType` strips the wrappers `typeOfAsString` already sees through, so all the + string literal types still share one `"string"` record rather than minting their own. +- **`TYPE_DESCR_*` is a cross-module contract**, even though every record has internal + linkage. A tag produced by one module is read back by another, so the reader applies *its* + idea of the record size to *the producer's* record. Same §4 hazard as the heap header, same + answer: pin the layout once. The release slot is reserved now for that reason, not because + anything calls it. +- **`any` comparison stopped paying for stringly-typed tags.** Asking "is this operand + numeric" ran nine `strcmp`s per operand, because `typeOfAsString` reports `"s32"`/`"f64"` + and not `"number"` for anything but a float. It is now one load and one compare against + `TYPE_KIND_NUMBER`, and it covers every numeric width instead of the nine that happened to + be listed. The width dispatch in `unboxNumericAsF64` stays name-based on purpose: the kind + says *numeric*, and it is the width that decides how many bytes to read back. + +### 9.4 Step 4: per-type release routines + +Landed 2026-09-03, full release suite green (829/829). Nothing calls them; the only reference +is the descriptor slot from §9.3, which is also what keeps them from being dead-stripped. + +The doc originally said "from the existing bitmap machinery". That machinery turned out to be +the unsound generator §9.2 retired, so this is built fresh — and built the other way round. +The old bitmap was *computed at run time*, with shifts and ORs into a stack array, which is +the root of all three of its defects. The pointer layout of a type is knowable at compile +time, so the routines are emitted as straight-line code with the offsets baked in. + +**Calling convention:** a routine takes a pointer to the *storage holding* a value, not the +value. That is uniform across value categories — a class field, an `any` payload slot and a +local all address the same way — and it makes releasing a field a plain GEP plus a call. + +**What each shape does:** + +| type | owns | routine | +| --- | --- | --- | +| `string` | its own block | null check, free | +| `array` | data block + elements | loop `0..length` calling E's routine, then free data | +| class / object | the instance block | release storage fields, free instance | +| `any` | its own box | read the tag's descriptor, call *its* release on the payload slot, free box | +| tagged union | nothing (payload inline) | same descriptor dispatch, no free | +| `optional` | nothing | release the value slot when the flag is set | +| tuple, class storage | nothing | release the fields, free nothing | + +The `any` and union rows are the payoff of §9.3: a value whose type is known only at run time +still resolves to a release routine, through the tag. + +Recursion works because the symbol is created before its body: `class Node { next: Node }` +emits a routine that calls itself. That also means a cyclic *object* graph would recurse +forever, which is the cycle problem of §5 showing up in concrete form rather than a new one. + +**Deliberately not released**, each for a stated reason: `InterfaceType` carries only a name, +so the layout behind its `this` pointer is not recoverable from the type and needs an RTTI +lookup rather than a static walk — *this one changed at §9.31: the value now carries the runtime +type tag of its `this`, which makes the lookup a tag read, so an interface is an owner*; +function types do not mention their capture box, so there is +nothing to walk even though the box is heap-allocated; `RefType`/`ValueRefType` point at +storage the value does not own; `ConstArrayType` and `ConstTupleType` are static data. A null +release slot says "nothing to release" positively — it is not an "unknown". + +#### The finding: static strings block releasing strings + +Writing the string routine surfaced a prerequisite that reorders the plan. A string literal +compiles to `store ptr @s_..., ...` — a `string` field can hold a pointer directly into a +read-only global that no allocator produced. `free(@s_... - headerSize)` corrupts the heap. + +This lands squarely on §9's recommended first shipping scope, which is **strings only**, +chosen because strings are leaves with no representable cycle. That scope is not reachable +until heap strings and static strings are distinguishable at run time. + +The consistent answer is the same one used twice already: give static string globals the same +block header, with an immortal marker in the count, so `__tslang_free_block` can test it and +skip. Every heap string already has that header from step 1, and every string pointer is +already `&bytes` of something — this only changes what precedes those bytes. It is deliberately +*not* part of this change: it touches every string literal in every module, on a hot path, and +deserves its own verification. + +So the order from here is: **static-string immortality first, then ownership tracking (step 5)** — +not straight to step 5 as originally written. + +**Cost note.** These routines are emitted under GC, where they are pure dead weight, so that +their construction and module verification are exercised by every test. That is the "verifiable +in isolation" the plan asked for, paid for in a few small internal functions per module. + +### 9.5 Step 4a: static blocks carry the header too + +Landed 2026-09-03, full release suite green (829/829). This is the prerequisite §9.4 turned up, +done straight away because it changes the layout of globals and so cannot be retrofitted. + +A string literal compiles to `store ptr @s_..., ...`. Before this, a `string` value was two +different shapes depending on where it came from — a heap payload with a header in front, or a +raw pointer into a read-only global — and nothing at run time could tell them apart. Every +global string now carries the same header word as a heap block, set to `HEAP_BLOCK_IMMORTAL`, +and `__tslang_free_block` skips a block that says it is immortal. + +The encoding is all-ones bytes, so the marker reads as `-1` whatever the word size or +endianness, and it stays a plain `[N x i8]` global with a `StringAttr` initializer — no +initializer region, and the existing `seekLast` placement still works. The global +is aligned to the header size so the word can be read as a word. Deliberately not zero: a +zeroed word is what a fresh heap block reads. + +**Every global string gets it, not just the ones that could be released.** Deciding +per-call-site which `getOrCreateGlobalString` produces a TypeScript string — as opposed to a +printf format or a symbol name — would be an audit whose failure mode is silent corruption in +exchange for saving eight bytes per constant. Uniformity is the same call made in step 2, for +the same reason. The `"true"`/`"false"` globals from a boolean cast are a good example of a +site that is not obviously a string value but is one. + +#### The tag was the 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 string value that can be released like any other. The descriptor's +name had nothing in front of it but the `release` field, which would have read as a very +mortal-looking count. + +The record therefore ends with the block header, immediately before the name: +`{ i32 kind, i32 reserved, ptr release, index blockHeader }`. Both reads now work off the same +pointer — `tag - sizeof(header)` is the immortal marker, `tag - sizeof(record)` the record — +and a tag is simultaneously a descriptor's name and a well-formed immortal payload. + +#### What this does not do + +Nothing writes the header on allocation, because nothing maintains a count yet. So the immortal +test is meaningful for static blocks, where the marker is baked into the initializer, and says +nothing useful about a heap block, whose word is whatever the allocator left. That half belongs +with maintaining the count, in steps 5 and 6 — the static half is separated out here only +because it is the half that changes an ABI. + +### 9.6 Step 5, part one: the flag exists and the count is maintained + +Landed 2026-09-03, 847/847 green — the suite plus 17 new `-mm=rc` variants and one `-mm=none`. +**This is not step 5.** Step 5 is ownership tracking in MLIRGen, and it is still the point of no +return; what this does is build the two things step 5 needs to exist first, both of which are +still inert. + +**`-mm={gc,rc,none}` replaces `-nogc`.** The flag cleanup this document has called for since the +first draft: there were always three models — `-nogc` meant "leak everything", not "collect +differently" — spelled as a single boolean. `-nogc` stayed on as a deprecated alias for +`-mm=none`, and `CompileOptions` grew `needsGCRuntime()` and `isRefCounted()` so no caller +reads the model enum directly. + +> **Removed, §9.47.** The alias is gone. An LLVM boolean option accepts an explicit value, and +> an empty one reads as *true* - so `-nogc= -mm=rc` silently compiled `none`, which is how a +> whole round of §9.46's reductions came back "already fixed". Every caller in the tree now +> spells the model outright. + +`-mm=rc` at this point means *counts are maintained and the release machinery is generated*; the +collector still runs and is still what frees. That is deliberately an intermediate: it makes the +header word real without anything depending on it being right. It held until step 6 (§9.28), which +took the collector out from under `rc` entirely. + +**Allocation initialises the count.** `_MemoryAlloc` stores 1 into the block header, after any +memset, so a block starts owned by exactly the reference being returned. **Only under +`-mm=rc`** — under `gc` nothing reads the word, and a store per allocation on the hot path is not +worth paying for dead code. Confirmed in the emitted IR: zero such stores under `gc`, one per +allocation site under `rc`. + +**The generated routines became real releases.** §9.4's routines destroyed unconditionally, +which is a destructor, not a release. Each one now drops a reference and only destroys when it +was the last: + +``` +if (p != null && __tslang_dec_ref(p)) { release fields; __tslang_free_block(p); } +``` + +`__tslang_dec_ref` is where the immortal marker from §9.5 does its work: an immortal block is +neither decremented nor ever the last, so a string literal and a `typeof` result survive being +released like any other string, without a write to read-only memory. `__tslang_free_block` is +now a plain free, since it is only reachable behind that test. + +The routines are reference-counting shaped in *every* model, because they are dead code in all +but `rc` and one shape is simpler than two. Only `rc` initialises the count they read. + +**Coverage.** The test runner gained the `-mm=` variant alongside `-fast-math`, using the same +per-variant cached-script trick, and 17 tests now run under `-mm=rc`: strings, arrays and their +elements, `any`, tagged unions, tuples, classes, interfaces, generators, closures, `delete`, and +unwind paths. These prove the model compiles and runs correctly across the shapes the routines +walk — **not** that counting is correct, which nothing yet exercises. `-mm=none` also picked up +its first test ever, since the old `-nogc` had none and the rename would otherwise have been +unguarded. + +**What is still ahead of step 5 proper.** Nothing calls a release, and nothing retains. Adding +those is the ownership tracking, and it is where a mistake stops being inert: a missing retain +frees live memory, an extra one leaks. That still wants the verifier the plan describes — every +owned value with a matching release on every path, unwind paths included — built alongside it +rather than after. + +> **Superseded in part 2026-09-03 (§9.12).** Locals retain and release. The verifier is still +> outstanding, and so is everything that is not a local: fields, elements, arguments, returns +> and temporaries. + +### 9.7 The memory-model marker + +Landed 2026-09-03, 847/847 green. The last outstanding piece of §4. + +A shared library records the model it was built under as an exported data symbol +`__tsmm___`. 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 — see +`decls-cross-module-declaration-mechanism` for why that 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. + +Verified end to end: a DLL built `-mm=gc` carries `__tsmm_gc_export_vars_`; importing it +`-mm=gc` is silent, importing it `-mm=rc` reports + +> shared library './export_vars.dll' was built with -mm=gc, this module with -mm=rc. Objects +> crossing between them are never reclaimed. + +and still runs, which is the agreed policy: allow the link, treat what crosses as immortal, leak +rather than double-free. + +**Two things this does not yet do.** Nothing marks crossing objects immortal — that lands with +ownership insertion, and until a release actually frees, a mixed link is harmless anyway. And +the mismatch path has no automated test: the 106 cross-module tests all build both sides the +same way, and giving the runner a per-side model would be more plumbing than the one warning is +worth. The marker's *presence* is covered by all of them, which is the part that could break +something. + +> **Closed by §9.77**: the default lib is now built per memory model, and a program links the +> one matching its own `-mm=`. + +**The consequence to keep in view:** the default lib is GC-built. Under `-mm=rc` everything it +allocates crosses a boundary and therefore leaks. Avoiding a per-model default lib is what the +allow-and-leak policy bought — this is the price of it, and it means `-mm=rc` will not be +leak-free for real programs until the default lib can be built per model. + +### 9.8 Weak references: `WeakRef` + +The decision on cycles is **weak references in the language**, rather than leak-and-document. +This section settles their representation, because it is ABI-shaped and this arc has been +sequenced around making those decisions before writing code. + +#### Surface: `WeakRef`, not a `weak` keyword + +JavaScript already has `WeakRef` with `.deref(): T | undefined` (lib.es2021.weakref). Using +that spelling costs no change to the vendored `ts-new-parser`, rides the generics machinery that +is already cross-module-complete, and is a shape TypeScript programmers know. + +The semantics come out *stronger* than JavaScript's, compatibly: `deref()` returns undefined +exactly when the last strong reference went, deterministically, rather than "whenever the +collector felt like it". Under `-mm=gc` it can be backed by a plain strong reference that never +returns undefined — a legal implementation of the JS contract, and one that keeps both models +working. `WeakMap`/`WeakSet` are out of scope. + +#### Representation: a weak count, and where to put it + +Something has to outlive the object to answer "is it dead". Three ways: a weak count beside the +strong one, a side table keyed by address, or a per-object indirection cell (which needs a +header slot or a table to be found, so it collapses into one of the other two). + +The objection to a weak count was that §9.5's uniform-header requirement would force it on +`-mm=gc` builds too — doubling a header that is already dead weight there. **That objection +dissolves once the header grows downwards.** Put the strong count immediately before the +payload and the weak count before *that*: + +``` + [ weak ] [ strong ] | payload + ^ the pointer everything holds +``` + +`strong` is at `payload - wordSize` in **every** model. That is the only field a cross-model +write touches — marking a foreign object immortal — so the uniformity §9.7 needs is preserved +while `weak` exists only under `-mm=rc`. GC builds keep the single word they have today. + +This does split one constant in two: the *block* size, used for allocation and free, and the +*strong offset*, used by the count operations. `getBlockPtrFromPayloadPtr` currently serves +both, and the count paths would move to the strong offset. + +Taking a weak reference to an immortal object — a string literal, or anything from a +differently-managed module — never touches the weak word: immortal means never dies, so the +reference is trivially always valid. That keeps a `-mm=rc` module from reading a second header +word that a `-mm=gc` module never wrote. + +#### Lifecycle + +Strong zero destroys, weak zero frees. When the last strong reference goes, the fields are +released as they are today, but the block itself survives while any weak reference remains — a +tombstone, distinguished by `strong == 0 && weak > 0`. `deref()` checks `strong > 0`, and if so +increments it and returns the object, so the referent cannot die between the check and the use. + +`WeakRef` is itself an owned type with its own release routine — decrement `weak`, free the +block if both counts are zero — which makes it one more shape for `ReleaseRoutineLogic` rather +than anything new. + +None of the count operations are atomic. That matches the rest of the compiler today and should +be revisited with threading, not before. + +#### What this does not solve + +An accidental cycle still leaks silently; weak references let a programmer break one they know +about. The natural follow-on is a debug-mode leak report at exit — every block whose strong +count never reached zero — which is cheap once counts are maintained, and is a far better answer +than a cycle collector for a language whose users can switch to `-mm=gc` with one flag. + +### 9.9 The first real caller: `delete` + +Landed 2026-09-03, 847/847 green. + +Everything before this generated release machinery that nothing called. `delete` is the one +place a reference is dropped that the language already spells out, so it makes the natural first +caller — and unlike ownership tracking it is a single lowering site, not a whole-program +analysis. + +Under `-mm=rc`, `DeleteOp` now drops a reference instead of freeing outright: the object goes +only if this was the last reference, and what it owns is released with it. Under `gc` and +`none` it still frees directly, so the default is untouched. Two things fall out that a bare +free did not give: an object's fields are released rather than leaked to the collector, and +`delete` can no longer free an immortal block. + +`ReleaseRoutineLogic::emitReleaseValue` is the entry point ownership tracking will reuse. The +per-type routines address storage rather than values, so it goes through a small value-taking +wrapper whose alloca sits in the wrapper own entry block — which keeps every caller from having +to find a safe place for one, since a release inside a loop must not grow the frame, and LLVM +inlines and promotes it away. + +Verified under both models: a class owning a string and an array of strings releases correctly, +and `delete` on a string literal leaves the static block untouched, which is the immortal marker +doing its job. `delete` on a plain string local emits no `DeleteOp` in either model — +pre-existing behaviour, unchanged here. + +**This is the first change that is not inert.** It only affects `-mm=rc`, and only `delete`, but +a release now actually frees. With no retains inserted yet every block still has a count of one, +so a released object is always the last reference — which is exactly the case ownership tracking +will complicate. + + +### 9.10 Step 4e: `ts.Retain` and `ts.Release` + +Ownership is now sayable in the dialect. `ts.Retain` records that a further owner holds a +value; `ts.Release` gives one owner's claim up, destroying the value and freeing its block when +it was the last. Nothing emits either yet, so this step is still inert. + +**The ops erase under any model that is not reference counting.** This is the design decision +the rest of the step follows from, and it is what makes "RC is an option" hold at the level of +the code rather than as an aspiration. MLIRGen can state ownership once, unconditionally, with +no `isRefCounted()` branching through it; the ops carry the intent and the lowering decides +whether it costs anything. + +It also reshapes the risk of step 5 considerably. 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, not merely expected to pass. Only the 17 `-mm=rc` tests can break, which is a +blast radius small enough to reason about. + +**Retain is not the mirror image of release, and the 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` — that is, only when the block is about to die and its fields' +references die with it. What does propagate 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. Getting this backwards leaks (retaining fields that were never released) or +double-frees (releasing fields that were never retained), and neither shows up until a count +is wrong much later, so the two builders sit next to each other in one file with the reasoning +written between them. `ReleaseRoutineLogic` became `OwnershipRoutineLogic` for that reason. + +`__tslang_inc_ref` skips a block marked `HEAP_BLOCK_IMMORTAL`, which is not an optimisation: +incrementing all-ones gives zero, and the next release would read that as "last reference" and +free a string literal. + +The descriptor record grew a retain slot beside the release one (`TYPE_DESCR_RETAIN`), 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, and the tag is what knows it. The +block header stays last, immediately in front of the name bytes, so a tag still reads as an +immortal string payload; the name simply 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, confirming the asymmetry holds in the generated +code and not just in intent. Temporarily emitting both ops at the `delete` site showed +`tsretv_`/`tsrelv_` calls under `-mm=rc` and *nothing at all* under `-mm=gc`, where only the +collector's `GC_free` remains; the hook was then reverted. Full release suite green: 847/847. + +### 9.11 Step 4f: `using` disposes when an exception unwinds with no enclosing `try` + +The reported gap: `using r = new Res(); throw 1;` at a function's top level, with no `try` +anywhere in that function, never ran `[Symbol.dispose]()` on the way out — confirmed at both +`-O0` and `-O3` before any fix. `mlirGen(Block)` disposed a `using` only on the block's *normal* +exit path; nothing gave it a landing pad to run from on `throw`. + +**The fix synthesizes a catch-less `TryOp` around a block that declares `using`.** Mirrors +`mlirGen(TryStatement)`'s own try-body/cleanup handling almost exactly - a real +`try { using x = ...; } finally {}` already goes through that path and already disposes +correctly on throw, so the synthetic version reuses it rather than inventing a second mechanism. +Catches and finally stay empty; `TryOpLowering` erases an empty catches region and wires the +cleanup block as a plain cleanup landing pad, so the exception is never caught, only cleaned up +after. + +**Building this surfaced four independent pre-existing bugs in the `TryOp`/dispose machinery, +none caused by this session's other changes.** Each was confirmed with 100% hand-written source +- an explicit `try`/`catch`/`finally`, no synthesis involved - before being treated as +out-of-scope for this step: + +1. **A `TryOp` with cleanup but no catch and no finally crashed the lowering.** Every TypeScript + `try` statement had always had at least one of catch/finally, so + `unwindDests.push_back(catchesBlock ? catchesBlock : finallyBlock)` in + `LowerToAffineLoops.cpp`'s `TryOpLowering` had never had to handle both being null. The + synthetic wrapper is the first thing to build a cleanup-only `TryOp` at all, so it's the + first thing to hit this. **This one is fixed, not just avoided** - a null `Block*` doesn't + belong in `unwindDests` in the first place, and the Linux side of the same function already + had the correct three-way fallback (`catchesBlock -> finallyBlock -> parentTryOpLandingPad -> + empty (resume)`) sitting right next to the broken Windows one, comment already anticipating + exactly this case. The Windows site now matches it. +2. **`TryOp` nested inside another `TryOp`'s body crashes the LLVM translation** with an LLVM + assertion (`Cannot assign a name to void values!`), reproduced by hand: + `try { try { using x=...; throw; } finally {} } catch {}`. Not fixed - guarded against: + `blockIsFunctionRootBody` restricts synthesis to a function's own top-level body, which by + construction can never be nested inside anything. + + > **Update (§9.17).** Fixed. `blockIsFunctionRootBody` was already gone by §9.13; what was + > left of this was a `using` one scope deeper than a hand-written `try`'s body, and it was + > `Win32ExceptionPass::ToInvoke` mangling an operation that was already an invoke. A + > `using` in a catch or finally *clause* is still guarded, by + > `blockIsInsideCatchOrFinally`, and was re-checked against the fix: a different cause. +3. **A block with its own `using` nested inside a `TryOp` that already has other `using`s + breaks MLIR verification** (`ts.PropertyRef` gets the wrong ref type for the inner + `using`'s dispose method), reproduced by hand: + `try { using a=...; { using c=...; } } finally {}`. Not fixed - guarded against: + `blockHasNestedUsing` scans (skipping into neither a nested function nor class) for a + `using` anywhere below the block's own top level. + + > **Update (§9.17).** Fixed, and `blockHasNestedUsing` is deleted. Same `ToInvoke` cause as + > item 2. The guard's cost was that the *outer* `using` stood down from being wrapped so + > the inner one could be, so it never disposed on unwind at all - the row in §9.13's table + > reading "outer skipped" in both columns. +4. **`using` plus `return` inside a `try` body is broken independent of throw entirely**, + reproduced by hand with the simplest possible shape: `try { using a=...; return; } finally + {}`. `mlirGenDisposable`'s `FullStack` walk at the return site and the try-body's own tail + dispose both try to dispose the same var. Not fixed - guarded against: `blockHasReturn` scans + the whole function body for any `return`. +5. **A separate, still-unexplained hang** (not a compile failure) turned up disposing an + *object-literal* `using` (`{ [Symbol.dispose]() {...} }`, as opposed to a class instance) + across an unwind with no enclosing try - reproduced with the exact same shape as the fixed + case, swapping only `new Res()` for a `loggy()`-style object literal, and confirmed the + synthesis correctly declined to wrap it (no `ts.Try` in the emitted MLIR) before the hang was + traced to the untouched pre-existing plain-dispose path. Guarded against the same way as the + others: `blockUsingInitializersAreAllNewExpr` restricts synthesis to `using x = new + SomeClass(...)` - every case actually verified working is written exactly this way. + +Since none of these four are cheaply detectable from a resolved *type* before generation (the +type isn't known yet - see `blockDeclaresUsing`'s own comment on why the check has to be +syntactic), each guard is a syntactic proxy for "would this hit the known-broken shape," +checked before deciding whether to wrap: `blockDeclaresUsing`, `blockIsFunctionRootBody`, +`blockUsingInitializersAreAllNewExpr`, `!blockHasNestedUsing`, `!blockHasReturn`. Failing any +of them falls back to the exact pre-existing plain-dispose path, byte-for-byte - a function that +doesn't qualify is no worse off than before this step, just not newly fixed either. The net +result is narrow: `using x = new SomeClass(...)` declared directly in a function's own +top-level body, with no other `using`-bearing scope and no `return` anywhere in that function, +now disposes correctly on `throw` with no enclosing `try`. Everything else - object-literal +disposables, `using` plus `return`, nested `using` scopes - is exactly as before: not fixed, +not worse. + +New test: `test/tester/tests/03disposable.ts` (`test-compile-03-disposable`, +`test-jit-03-disposable`) - the originally reported shape, now asserting dispose actually ran. +Full release suite green: 849/849 (847 existing + the 2 new). + +> **Re-audited 2026-09-03 (§9.13). Two of the four guards were already stale when written and +> have been deleted; two of the "pre-existing bugs" above do not reproduce.** Read §9.13 rather +> than this list for the current state. + +### 9.12 Step 5a: locals own what they hold + +The first slice of step 5, and the first time anything in the compiler calls a retain or a +release on its own account rather than because the program said `delete`. Full release suite +green: 852/852 (849 existing plus 3 new). + +**The rule.** 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. + +**Stated unconditionally, and the collected build shows no trace of it.** MLIRGen never asks +which memory model is in force; it emits `ts.RetainSlot` / `ts.ReleaseSlot`, and the lowering +decides. Confirmed by reading the emitted LLVM for the same file under both models: under `rc` +the retain sits immediately after the initialising store and the releases sit in reverse +declaration order at each exit; under `gc` the two functions are **instruction-for-instruction +what they were before this step** — not a dead load left for a later pass to remove, because +the slot-addressed ops erase whole and take the access with them. That is what the new +`ts.RetainSlot`/`ts.ReleaseSlot` pair buys over the value-addressed `ts.Retain`/`ts.Release` +from §9.10, which would have needed a load kept alive under a collector 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 (§9.6) is never given up here. Every release this step +emits is therefore paired with a retain this step emitted, so no release can outnumber its +retains 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 that surfaces far from its cause, and a leak is not. Removing +the slack is later work, and each piece of it is a separate decision: consuming the +1 when the +initialiser is a fresh allocation, retaining on field and element stores, and releasing +temporaries. + +**Where a local is *not* made an owner**, each because the frame borrows the reference rather +than owning it, and releasing one would drop a count nobody took: + +- globals, which outlive every scope; +- parameters — only variable declarations reach the hook, so a parameter's slot is never + marked, and assigning to a parameter neither retains nor releases; +- captured variables held in the `this` context, whose slot belongs to the context; +- `const` bindings with no storage, which have no slot to release from; +- **declarations with no initialiser.** This one was found the hard way and is the single bug + this step produced: a `catch (v: string)` variable is declared like any other `let` but + written by the landing pad, not by an initialiser, so retaining at the declaration read an + uninitialised slot as a live reference and trapped. `00try_catch.ts` under `-mm=rc` was the + only test in 849 that failed, which is exactly the blast radius §9.10 predicted. The + consequence is that a `let s: string;` assigned later never becomes an owner either — + correct rather than merely safe, since the assignment path only fires on a slot the + declaration marked, so that stays balanced too. + +**The unwind leg is skipped, on purpose.** An owned local's storage is allocated inside the +`TryOp` body region, which does not dominate the cleanup region, so a release emitted there +would not verify. Disposal still runs on that leg (§9.11); the release does not, which leaks +the reference when an exception passes through. Fixing it means hoisting owned storage out of +the operation the way `using` variables already are (`allocateUsingVarsOutsideOfOperation`) — +tractable, and left for the step that also brings the verifier. + +> **Update (§9.15).** Done. The hoisting landed, the dominance problem is gone, and the +> release now runs on the unwind leg too, so the leak described here no longer happens. + +**Where it hooks in.** Three points, all of them ones that already existed: + +- `takeOwnershipOfLocal` (`MLIRGenVariables.cpp`), called from `registerVariable` right where + `usingVars` is collected, marks the storage with `__owned` and emits the retain. +- `mlirGenScopeExit` (`MLIRGenImpl.h`) wraps `mlirGenDisposable` and the new + `mlirGenReleaseOwned`, so all eleven existing scope-exit call sites — block end, `return`, + `break`, `continue`, try body — got the releases for free. Disposal runs first: a disposable + is still usable while its `[Symbol.dispose]()` runs, and dropping the last reference first + could have freed it. +- `mlirGenSaveLogicOneItem` (`MLIRGenImpl.h`) is the single choke point every assignment form + passes through — plain, compound and destructuring alike. Retain-then-release, in that + order, is what makes `x = x` safe: releasing first could drop the last reference and free the + value about to be stored back. + +`ownsHeapMemory` moved from `OwnershipRoutineLogic` to `MLIRTypeHelper` so that 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. + +**Coverage.** `test/tester/tests/00owned_locals.ts`, run under all three models +(`test-compile-00-owned-locals`, `test-jit-00-owned-locals`, `test-jit-rc-owned-locals`). +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, b] = [b, a]`), 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. + +### 9.13 Re-auditing the `using` guards: half of them were already unnecessary + +§9.11 added four conditions, each meant to keep the synthesized `TryOp` away from a shape that +crashed. Each was real when observed. But they were all observed *before* §9.11's own +`unwindDests` fix landed, and that fix — the cleanup-only `TryOp` that pushed a null `Block *` +— turned out to be the cause of more of them than the notes credited. Re-running every guarded +shape against the current build: + +| shape | before | after | +|---|---|---| +| `using` in an `if` block, throw | dispose skipped | **disposes** | +| `using` in a loop body, throw | dispose skipped | **disposes** | +| `using` two scopes deep, throw | dispose skipped | **disposes** | +| `using` sharing a function with `return`, throw | dispose skipped | **disposes** | +| `using` inside a hand-written `try` | worked | works | +| object-literal `using`, throw | dispose skipped | dispose skipped | +| outer `using` with a nested `using` scope, throw | outer skipped | outer skipped (**both dispose since §9.17**) | + +**`blockIsFunctionRootBody` and `blockHasReturn` are deleted.** Both were guarding shapes that +now work. Dropping the root-body condition is the one that matters: synthesis is no longer +confined to a function's own top-level body, so a `using` in an `if` branch, a loop body, or a +block nested inside a hand-written `try` all dispose on the way out. Nested `TryOp`s, which +§9.11 recorded as crashing LLVM translation, compose correctly — `try/catch` inside +`try/catch`, and a synthesized cleanup inside a hand-written `try`, both verified. + +**`blockUsingInitializersAreAllNewExpr` and `blockHasNestedUsing` stay, and each was confirmed +individually necessary** by dropping it alone and rebuilding: without the first, an +object-literal disposable fails the build; without the second, an outer `using` whose block +also contains a nested `using` scope segfaults the compiler. Those are the two genuinely open +bugs, and they are now stated in terms of what was actually reproduced rather than what was +inferred. + +> **Update (§9.17).** `blockHasNestedUsing` is now deleted too — the segfault it was standing +> in front of was `Win32ExceptionPass::ToInvoke`, not anything about nesting. The method here +> is what made that possible to check: confirming a guard is *individually* necessary is what +> turns it from folklore into a one-line experiment to redo after any fix in the area. +> `blockUsingInitializersAreAllNewExpr` was re-checked and stays. + +Method worth repeating: the gate was made maskable by an environment variable for the duration +of the experiment, so one build could test all sixteen combinations. Four rebuilds' worth of +bisection in a single compile, and the mask made "necessary individually" a question that could +be asked directly instead of argued from a combined result. + +**Separately, a genuinely new pre-existing bug, unrelated to any of this.** Throwing from +inside a `catch` clause crashes the LLVM backend (`X86 Assembly Printer`, access violation) — +reduced to `try { throw 1; } catch (e: int) { throw 2; }` with no `using`, no locals and no +heap types anywhere in it, so neither ownership insertion nor the `using` machinery can be +involved. Recorded here because it surfaced while building the matrix above; not fixed, and no +test asserts it, which is why nothing caught it before. + +> **Fixed 2026-09-03, see §9.14.** + +New test: `test/tester/tests/04disposable.ts` (`test-compile-04-disposable`, +`test-jit-04-disposable`), 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. + +### 9.14 Throwing out of a `catch` clause + +`try { throw 1; } catch (e: int) { throw 2; }` crashed the compiler. The cause is one missing +line, and the shape of it is worth keeping. + +`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 for +itself by splitting the block *ahead* of the throw, and emits the `catchret` there. The result +is a `catchret` followed by a call that still carries `"funclet"(token %catchpad)` — a bundle +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 needed a new side table rather than the existing one.** The other three record "I am +leaving a catch" by having `tsContext->unwind[op]` set. A throw cannot: for a throw that map +already means its invoke destination, and the finally handling writes exactly that into it. So +`leavesCatch` is its own set, populated by the same walk over the catches region that already +marks returns. + +**And only when there is no `finally`.** With one, the throw becomes an invoke into the finally +block and *the finally* ends the catch; ending it at the throw as well runs it twice and breaks +the unwind. `51exceptions.ts` — `catch (e: number) { … if (k >= 10) throw e } finally { … }` — +is the case that proves it, and it caught the first version of this fix. + +**Still open, and 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 (`catch (e) { thrower(); }`) loses it too, with no `throw` statement + involved anywhere and nothing in this change able to affect it. The IR is well-formed at + both `-O0` and `-O3`; the gap is in the AOT exception tables. `00throw_in_catch.ts` is + therefore registered JIT-only. + + > **Update.** Both wrong, and differently wrong. The first was the + > `CatchableType::sizeOrOffset` miscompile (§9.15) and went away with it; this file's tests + > now run under AOT as well, as `test-compile-00-throw-in-catch`. The second was neither + > AOT-specific nor in the exception tables: the MLIR inliner was **erasing the throw** + > (§9.16). "The IR is well-formed" was checked on the callee, which is exactly the function + > that survives intact — the deletion happens at the call site. + +- **A call inside a catch followed by a throw out of it** (`catch (e) { new Res(); throw 2; }`) + crashes at run time, AOT and JIT alike, at every optimisation level and memory model. Its IR + is well-formed too. Unrelated to ending the catch. + + > **Update.** Also the `CatchableType::sizeOrOffset` miscompile (§9.15); fixed there, and + > covered now by `00try_using_catch.ts`. Correctly identified as unrelated to ending the + > catch — it just wasn't an EH bug at all. Three of these entries had one cause between + > them, and the thing they had in common was a *call in a catch*: that is the shape whose + > frame layout the overflow reached. + +- **Throwing from a `finally`** (`try { throw 1; } finally { throw 2; }`) segfaults, from the + same `CutBlock` cause — `ts.BeginCleanup` with no `ts.EndCleanup`. Not fixed here because + `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. + +**A regression in §9.13 turned up while testing this, and is fixed here too.** Dropping +`blockIsFunctionRootBody` 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. §9.13's matrix checked nesting inside a +try *body* and never inside a catch region. `blockIsInsideCatchOrFinally` restores exactly that +half; the four shapes §9.13 fixed all still work. + +The same predicate also excludes those clauses from ownership (§9.12): under `-mm=rc` a release +in a catch clause is a call inside a funclet, which is the fragile construct above, and +`catch (e: int) { let r = new Res(); }` segfaulted. Locals there are simply not owned now — they +leak, the trade every other exclusion in §9.12 makes. (That leak was covered by the collector when +this was written; since step 6 (§9.28) it is a real one under `-mm=rc`.) +Both holes existed because no test had a `using` or a heap local inside a catch clause; +`04disposable.ts` now has both, and `03disposable.ts`/`04disposable.ts` gained `-mm=rc` +variants, which is what would have caught the ownership half. + +New test: `test/tester/tests/00throw_in_catch.ts` (`test-jit-00-throw-in-catch`, +`test-jit-rc-throw-in-catch`). Full release suite green: 858/858. + +### 9.15 Step 5b: owned storage is hoisted out of the `TryOp`, and the unwind leg releases + +§9.12 left one hole on purpose: an owned local's storage was allocated inside the `TryOp` body +region, which does not dominate the cleanup region, so the release could not be emitted on the +unwind leg and the reference leaked when an exception passed through. This step closes it — and +turned up a miscompile of our own on the way, which is the more valuable half of the result. + +**What landed.** Owned storage is hoisted out in front of the `TryOp`, exactly the way `using` +storage already was. `allocateUsingVarsOutsideOfOperation` is renamed +`allocateScopeOwnedVarsOutsideOfOperation` because it now serves both, and the hoist decision +for an owning local cannot be made in `detectFlags` with the rest — it needs the variable's +type, which is not known until `createLocalVariable`. Verified in the emitted LLVM: the +`alloca` moves to the function entry and the initialising store stays at the declaration, in +both memory models. Nothing else about a collected build changes. + +**One predicate, two callers.** `localTakesOwnership` is the single test for "does this +declaration make its scope the owner", shared by the hoisting decision and by +`takeOwnershipOfLocal`. They must agree: 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 and the module stops verifying. This is the same lesson as moving `ownsHeapMemory` +into `MLIRTypeHelper` in §9.12 — two sides asking the same question separately is the failure +mode with no local symptom. + +**Hoisted storage starts null, under `-mm=rc` only.** A hoisted slot's initialising store stays +behind at the declaration, and the unwind edge can reach the cleanup region before that store +runs — the allocation in `let r = new Res()` is itself an `invoke` whose unwind destination is +that region. A release there would read whatever the frame happened to hold, which is precisely +how the catch-variable bug in §9.12 trapped. Null is the one value every release routine treats +as nothing to do (`emitIfLastReference` null-checks first), so `VariableOpLowering` zero-fills a +hoisted owned slot. Gated on `isRefCounted()` in the *lowering*, not in MLIRGen: no other model +reads the slot before its store, and a collected build is meant to come out of this step +byte-identical. + + +**The unwind leg releases.** The cleanup region now calls `mlirGenScopeExit` rather than only +`mlirGenDisposable`, so an exception passing through a scope gives back the references that +scope took. Confirmed in the emitted LLVM: under `rc` the cleanup funclet holds one `tsrel_` per +owned local, in reverse declaration order, each carrying the funclet bundle; under `gc` the same +region is empty, because the slot-addressed ops erase whole. Step 5's local half is now complete +on every path. + +#### The detour: a miscompile of our own, found because this step tripped it + +Turning the release on broke exactly one test, and chasing it turned up a bug that had nothing +to do with reference counting and had been in the tree the whole time. + +The shape, which needs no ownership at all and fails under `-mm=gc`: + +```ts +function f() { + try { using r = new Res(); throw 1; } + catch (e: TypeOf<1>) { print("a"); print("b"); } +} +``` + +`main` keeps a pointer in `rsi` across the call to `f` — legal, `rsi` is callee-saved — and gets +it back with its **low 32 bits zeroed**. + +**Root cause: `CatchableType::sizeOrOffset` said a caught `int` was 8 bytes.** Both RTTI helpers +(`MLIRRTTIHelperVCWin32.h` and `LLVMRTTIHelperVCWin32.h`) hardcoded `8` for every catchable +type. The CRT copies exactly that many bytes into the catch variable's frame slot, so catching a +4-byte `int` wrote 8 and clobbered whatever sat above the slot. The symbol name we emit had been +saying so all along: `_CT??_R0H@8` **4** — the trailing digit is the size, and it disagreed with +the record it named. + +**Why it hid for so long.** What sits above the catch slot is a question of frame layout. Ahead +of time it was padding, so the overflow was invisible. The JIT compiles with the **large code +model**, where every call materialises a 64-bit address into a register; that pressure makes a +catch funclet use a callee-saved register, which makes the parent save it, which puts a saved +register exactly where the overflow lands. Hence: JIT-only in practice, sensitive to unrelated +code changes, and not reproducible with clang — clang emits `4`. + +**How it was found**, because the route generalises. `llc -code-model=large` on the same IR +reproduced it ahead of time, which exonerated the JIT's unwind-table registration and turned a +compiler-rebuild loop into a seconds-long one. clang's C++ equivalent at `-mcmodel=large` did +*not* reproduce, which said the defect was in our IR rather than the backend. Deleting the +cleanup funclet still reproduced, which said the `using` was a red herring. A hardware +write-breakpoint on the saved-register slot then named the writer: an 8-byte store from inside +the CRT's EH machinery, of the value `1`, at establisher+52 — the catch object, one word wide +for a four-byte `int`. + +**The fix** gives each catchable type its real size: `int` is 4 and `double` is 8 on every +target, while the pointer-shaped ones (string, opaque pointer, class reference) take +`compileOptions.sizeBits / 8`, since those genuinely do follow the architecture flag. Both +helpers were wrong identically and both are fixed; leaving one behind is the classic trap with a +duplicated table. Worth recording while in there: the whole name table in +`LLVMRTTIHelperVCWin32Const.h` is 64-bit MSVC mangling (`PEA` is a `__ptr64` pointer, and +pointer entries bake `@88` into the symbol), so a 32-bit target needs its own table, not just a +different size. + +New test: `test/tester/tests/00try_using_catch.ts`, run under all three models +(`test-compile-00-try-using-catch`, `test-jit-00-try-using-catch`, +`test-jit-rc-try-using-catch`, `test-jit-none-try-using-catch`). It covers the caught-`int` case +that was broken and a caught `number`, which is genuinely eight bytes and has to keep working +now that `int` narrowed. Writing it turned up one more thing worth recording: moving the `using` +one scope deeper, into an `if` inside the try body, crashes the *compiler* in every memory +model. That is §9.11's second item — a synthesized cleanup `TryOp` nested inside a real +`TryOp`'s body — still open, and it is why the test covers only the flat shape. + +> **Update (§9.17).** Fixed, and `00using_nested_scopes.ts` now covers the deeper shape. + +Full release suite green: 862/862. + +### 9.16 The inliner was deleting throws + +Not an RC bug at all, and not an EH bug either — a silent wrong-code bug in the ordinary +optimised build, found by re-testing §9.14's open list after the §9.15 fix and asking why one +entry survived. Two defects, one behind the other. + +**A function whose body ends in a throw inlined down to nothing.** This: + +```ts +function thrower() { throw 5; } +function callsIt() { thrower(); } +``` + +compiled, under `--opt`, to a `callsIt` that does nothing but return: + +```mlir +ts.Func @callsIt !ts.func<, , false> { + "ts.ReturnInternal"() : () -> () +} +``` + +MLIR's inliner has a fast path for a single-block callee (`inlineRegionImpl`, the +`singleBlockFastPath` branch): it offers the block's terminator to the dialect's +`handleTerminator` hook and then calls `firstBlockTerminator->erase()` **unconditionally**. The +assumption is that a terminator is return-like and its operands are all the block had left to +say. `ts.ThrowCall` is a terminator too, and `TypeScriptInlinerInterface::handleTerminator` only +ever did anything for `ReturnInternalOp` — so the throw was handed over, ignored, and erased. +The multi-block path has no such erase, which is why a *conditional* throw was always fine and +only the throw-only helper was hit. + +The fix is the hook MLIR provides for exactly this, `allowSingleBlockOptimization`: decline the +fast path unless the terminator is a return. The multi-block path then leaves the throw in place +as the block's terminator and puts the code after the call site in an unreachable block, which +is what it should have been all along. + +**Then the same throw inlined into a `catch` clause crashed the backend** — the case that had +been recorded as "lost under AOT, so the gap is in the AOT exception tables". It was neither. +`Win32ExceptionPass` ends a catch region at a `_CxxThrowException` call by splitting the block +*ahead* of it and emitting the `catchret` there, which leaves the throw outside the funclet; but +it also collected that same call into `catchRegion.calls`, which is what stamps +`"funclet"(token %catchpad)` on. So the throw named a pad it had already returned from — the +identical malformed shape §9.14 describes, reached by a different route. An `__cxa_end_catch` +marker is what normally keeps the two apart, by closing the region before the throw is reached, +and a throw the inliner brought in has no marker: the `EndCatchOp` that followed the call it +replaced went with the rest of the now-unreachable code after it. + +**The first attempt at that overreached, and 00try_catch.ts caught it.** Closing the region on +the throw, the way the marker does, broke three tests. That scan walks `instructions(F)` in +order rather than by region, so once inlining has merged several functions into one, a throw +belonging to one catch turns up while another is still open — and closing there strands the rest +of that catch's calls with no bundle at all. Skipping the call is all that is needed; the region +stays open. The `isCatch()` guard matters too: a cleanup region gets no `catchret`, so its throw +stays inside the funclet and does still need the bundle. + +**What this says about the earlier diagnosis.** Three entries on §9.14's open list had two +causes between them, and both diagnoses pointed at the runtime — "the AOT exception tables", "it +is the runtime side that drops it" — on the strength of the IR being well-formed. It was: the IR +of the *callee*, which is the one function the bug leaves intact. The deletion happens at the +call site, and the call site was never looked at. The cheap check that would have settled it in +minutes is the one that eventually did — dump `--emit=mlir-affine` with and without `--opt` and +diff, which is a much smaller step than reasoning about exception tables. + +New test: `test/tester/tests/00throw_inlined.ts`, run under all three models +(`test-compile-00-throw-inlined`, `test-jit-00-throw-inlined`, `test-jit-rc-throw-inlined`, +`test-jit-none-throw-inlined`). It covers the plain call, the call from inside a catch clause, +and a conditional throw as the control that always worked. `00throw_in_catch.ts` picks up its +AOT variant here as well, now that nothing on its header's list is true any more. + +Full release suite green: 867/867. + +### 9.17 A nested `using` scope, and the guards that were standing in for one bug + +Two of §9.11's guards turned out to be avoiding the same defect, in a place neither of them +named. Fixing it retires one guard outright and closes the last two `using`-on-unwind gaps. + +**The shapes.** Both crashed the compiler, in every memory model: + +```ts +try { if (flag) { using r = new Res(); throw 1; } } catch (e: int) { } // one scope deeper +using a = new Res(); { using c = new Res(); } throw 1; // outer plus inner +``` + +The first was §9.11's item 2, guarded by `blockIsFunctionRootBody` and, once that went in +§9.13, by nothing — it simply crashed. The second was item 3, guarded by +`blockHasNestedUsing`, whose cost was that the *outer* `using` stood down from being wrapped so +that the inner one could be, and therefore never disposed on unwind at all. + +**One cause: `Win32ExceptionPass::ToInvoke`.** The helper 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 two of its callers hand it an operation that is *already* an invoke — the +"fix incorrect landing pad" loop that redirects an invoke whose unwind destination is wrong. An +invoke already ends its block, so splitting at it puts it alone in the new continuation block, +and every caller erases it immediately afterwards. What is left is an empty block with no +terminator, and the real continuation stranded with no predecessors: + +```llvm + %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 +``` + +That reaches `AlwaysInlinerPass`, which 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 calling `setUnwindDest` is what the funclet-bundle loop a few hundred +lines above already does. + +**Then the guards were re-tested, one at a time.** This is the payoff and the reason §9.13 was +careful to establish that each guard was *individually* necessary — that turns "is this still +needed?" into a one-line experiment rather than an argument. + +- `blockHasNestedUsing` — **deleted.** The outer and inner `using` now both dispose on unwind, + innermost first. +- `blockIsInsideCatchOrFinally` — **stays.** Dropped alone, `catch (e: int) { using r = new + Res(); }` still crashes. A different cause, still open. It is worth naming its second cost: + `localTakesOwnership` consults the same predicate, so a heap local declared in a catch or + finally clause is not owned and leaks under `-mm=rc`. + > **Both halves re-measured false in §9.65.** That shape now runs in all three models, and the + > local does not leak: measured behind an interface it reclaims exactly as well as the same + > allocation one scope out, because §9.30's discarded-temporary pass consumes the reference + > whether or not MLIRGen made the local an owner. Whether the predicate can now go is untested. +- `blockUsingInitializersAreAllNewExpr` — stays, re-checked, unchanged. + +**Also still open, and confirmed independent:** throwing from a `finally` still crashes the +compiler, in both memory models. That is the `ts.BeginCleanup`-with-no-`ts.EndCleanup` shape +§9.14 describes, and this fix does not touch it. + +> **Stale as of §9.65.** It compiles and runs in all three models now, and the throw is caught. + +New test: `test/tester/tests/00using_nested_scopes.ts`, run under all three models +(`test-compile-00-using-nested-scopes`, `test-jit-00-using-nested-scopes`, +`test-jit-rc-using-nested-scopes`, `test-jit-none-using-nested-scopes`). 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. + +### 9.18 The verifier, and the first thing it found + +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. + +**Where it runs, and in which model.** `OwnershipVerifierPass`, 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 of model 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. + +**What it checks.** For each `ts.RetainSlot`, a backward must-analysis over the function's +blocks: is there a path from the retain to a function exit that passes no `ts.ReleaseSlot` on +the same slot? A block releases if it does so directly or inside a region of one of its own +operations — counting nested regions as releasing rather than as opaque, because a verifier that +reports a leak the IR does pay somewhere the walk does not follow is a verifier that gets +switched off. Being a must-analysis it starts optimistic and is driven down to a fixed point, +which leaves a loop with no exit reading as satisfied — correctly, as it has no path to an exit +to leak on. The cheap structural half of the other direction is there too: a release naming a +slot that is never retained. + +It checks the direction that leaks rather than the direction that frees live memory, on purpose. +Step 5a's insertion is balanced by construction, so an unmatched release cannot currently be +generated; what an extension to fields, elements, arguments or returns will get wrong first is a +path out that nobody released on. + +**Confirmed to fire before being trusted.** A verifier that has never failed is a verifier that +might not work. The unwind-leg release from §9.15 was reverse-applied, and it reported the leak +at the right declaration, in all three memory models; restored, it went quiet again. + +**What it found on its first run.** 460 test files, two with findings, both real. + +1. **A `break` or `continue` written inside another block skipped every scope between itself and + the loop** — the disposals a `using` declared *and* the references those scopes' locals took. + Not an RC bug: `using` had it too, and that half is user-visible. 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. `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" — and the very first step of the walk + 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 attempts either side of it were wrong and are worth recording. Carrying the target label + into the recursion instead of the empty one looks obviously right and 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 label 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 — broke `Path.ts`. The `isLoop` fix made it unnecessary anyway. + +2. **A `[Symbol.dispose]()` that itself throws during unwind skips the release that follows it.** + The cleanup region invokes dispose, and its unwind edge goes to the enclosing catch without + passing the `ts.ReleaseSlot` on the far side. Real, and left alone: releasing before disposing + would fix the path and break the ordering §9.12 chose deliberately — a disposable is still + usable while its `[Symbol.dispose]()` runs, and dropping the last reference first could have + freed it. `00using_nested_scopes.ts` is the one file that still reports. + +New tests: `test/tester/tests/00break_continue_scope_exit.ts`, all three models +(`test-compile-00-break-continue-scope-exit`, `test-jit-00-break-continue-scope-exit`, +`test-jit-rc-break-continue-scope-exit`, `test-jit-none-break-continue-scope-exit`). 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. + +### 9.19 Step 5c: fields own what they hold + +The first piece of step 5 beyond locals, and the one the verifier from §9.18 was built ahead of. + +**The gap.** A field store was a bare `ts.Store`. The runtime half had been in place since §9.4 — +`releaseFields` in `OwnershipRoutineLogic` walks an instance's fields when its release routine +runs — but nothing ever took the reference that routine was giving up, and overwriting a field +dropped the outgoing value on the floor without releasing it. + +**The fix** is the one already written for locals, applied to a second kind of storage. +`isOwnedLocalSlot` becomes one arm of `isOwningSlot`; the other is `isOwnedFieldSlot` — a +`ts.PropertyRef` whose base is a class or object instance, and whose field type owns heap memory. +Retain the incoming value, release what the slot still holds, then store. Retaining first is what +makes `h.item = h.item` safe. + +**Scoped deliberately.** A field of a record held *inline* — a tuple in a local, a parameter's +slot — is not covered. Its fields are released by whatever owns the record, which is only tracked +when that is an owned local, and retaining into a record nothing releases would leak. That is the +same question arguments and elements ask, and it gets one answer, later, not three. + +**The counting stays balanced by construction.** A freshly allocated value's birth reference is +still unconsumed, so every count sits one above the truth, uniformly, now on fields as well as +locals. Nothing can reach zero on a live value, which is the property §9.12 chose and this keeps. + +**Which means the new tests have no teeth yet, and that was checked rather than assumed.** +Swapping the store to release-before-retain — the classic way to free the value you are about to +store back — leaves every case in `00owned_fields.ts` passing, because a release cannot reach +zero while the slack is there. They are written as aliasing cases anyway, and run in every model, +because that is exactly what gives them teeth the moment the slack goes. + +**One thing this broke in the verifier, worth recording.** The structural half of §9.18 — +"released but never retained" — went from zero findings to **49 files**. All false. An overwrite +hands the count over with `ts.Retain` on the *value* coming in and `ts.ReleaseSlot` on the *slot*, +so the slot never appears in a `ts.RetainSlot` and every field store in the suite looked +unmatched. The check now recognises the hand-over by the store that follows the release. The +lesson is about verifiers rather than about fields: a check that pairs acquisitions and releases +has to know every shape the pairing takes, and adding an insertion point adds a shape. + +After that, the verifier reports **two** functions across the whole suite, and both are the same +throwing-`[Symbol.dispose]()` path §9.18 already documented — the cleanup region's own dispose +invoke unwinding past the release that follows it. No new findings from this step. + +New test: `test/tester/tests/00owned_fields.ts`, all three models +(`test-compile-00-owned-fields`, `test-jit-00-owned-fields`, `test-jit-rc-owned-fields`, +`test-jit-none-owned-fields`). Repeated overwrite, an alias that outlives the field's reference, +self-assignment, one value shared between two holders, and a field assigned from another field. + +Full release suite green: 879/879. + +### 9.20 Step 5d: elements own what they hold — and the literal that does not + +The direct sibling of §9.19. A `T[]` value is `{ data, length }`, and its release routine walks +the elements of the data block before freeing it (`buildArrayBody` in `OwnershipRoutineLogic`) — +the exact 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`. + +**The fix** is a third arm on `isOwningSlot`: `isOwnedElementSlot` — 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 at all. Element access already produces `ts.Load` on a +`ts.ElementRef`, so the store flows through the same assignment path fields do and needed no new +emission code — only the predicate. + +**Scoped deliberately.** `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 — give up a reference to a value the +caller is about to hold — so those belong together in one later slice rather than half here. + +**These tests do have teeth, unlike §9.19's, and that is the interesting part.** The same +release-before-retain swap that left every field case passing makes `test-jit-rc-owned-elements` +fail outright: the element self-assignment reads back `0` where the field self-assignment still +reads `5`. Reduced to two five-line programs, that asymmetry is not about elements at all. + +**What it exposes: an array literal stores its elements without retaining them.** The IR for +`let arr = [kept];` is a `ts.CreateArray(%kept)` with no `ts.Retain` anywhere near it, while the +`ts.ReleaseSlot` at scope exit runs the array's release routine, which releases every element. +The array gives up a reference it never took. A field filled through the assignment path holds +birth + field = 2 and survives a stray release; an element seeded by a literal holds only its +birth reference, so releasing first drops it to zero and frees a live value. + +Object literals construct the same way and have the same hole. That makes literal construction, +not arguments or returns, the next thing to take. + +> **Correction, made while implementing §9.21.** This section originally called the gap an +> over-release *in waiting*, masked entirely by the slack, and illustrated it with an array going +> out of scope and releasing an element it never retained. That mechanism is wrong: the data +> block has an unconsumed birth reference of its own, so it does not die at scope exit and never +> reaches its elements at all. The real mechanism is that the element is simply one count below +> an equivalent field, and it is *each explicit overwrite* that spends the missing reference — +> the first cancelled by the birth slack, the second going past zero. Which means it was never +> latent: it frees live memory today. §9.21 has the reduced case. + +The verifier is unchanged by this step: still the same two files and six retain sites, all the +known throwing-`[Symbol.dispose]()` path, and no new "released but never retained" — the +hand-over recognition added in §9.19 generalised to elements without modification. + +New test: `test/tester/tests/00owned_elements.ts`, all three models. Repeated overwrite, an alias +outliving the element's reference, self-assignment, one leaf shared between two arrays, an element +assigned from another array's element, one value reaching two slots of the same array, and +overwriting a single slot inside a loop. + +Full release suite green: 883/883. + +### 9.21 Step 5e: literal construction, and the first over-release that was already live + +§9.20 ended by predicting that array and object literals capture without retaining, and filed it +as a latent problem for after the slack came out. Writing the fix meant reducing the case +properly, and the reduction said something different: it frees live memory now. + +**The reduced case.** Two array literals holding one value, each overwritten once: + +```ts +let kept = new Leaf(7); +let a = [kept]; +let b = [kept]; +print("A", kept.n); // 7 +a[0] = new Leaf(1); +print("C", kept.n); // 7 +b[0] = new Leaf(2); +print("D", kept.n); // 0 <- freed while `kept` still holds it +``` + +**Why §9.20's account of it was wrong.** That section said the array dies at scope exit and +releases an element it never retained. It does not: the data block carries an unconsumed birth +reference of its own, so its count never reaches zero and its release routine never runs. The +elements are not reached that way at all. + +What actually happens is quieter and worse. An element seeded by a literal sits at **one** — +its birth reference only — where a field filled through the assignment path sits at two. Every +`arr[i] = x` releases what the slot held. The first such release is exactly cancelled by the +birth slack, which is why one overwrite looks fine and why §9.19's and §9.20's tests pass. The +**second** release of the same value, through a different literal, has nothing left to spend and +takes it past zero. Two holders and two overwrites is the whole recipe, and it needs no future +change to become reachable. + +So the slack was never masking this. It was masking exactly one release of it. + +**The fix** is one helper, `mlirGenRetainCaptured`, used at the two places that fill an owning +block in one go instead of through an assignment: the array literal's `ts.CreateArray`, and the +boxed object literal's `ts.New` + `ts.Store`. Both blocks release what they hold when they die, +so both must take a reference to it. A record-shaped value retains through its own routine, which +walks its owning fields, so the boxed case needs one `ts.Retain` on the whole tuple rather than +one per field. + +**Still open, and now precisely bounded.** The spread form of an array literal (`[...xs, y]`) +builds its array through `ts.ArrayPush` rather than `ts.CreateArray`, so it keeps the same hole +until §5f takes the mutating ops. An unboxed object literal — one with no methods — stays an +inline const-tuple or tuple, which is the inline-record case §9.19 deferred and §5g will answer. + +**The test does have teeth, and each case was checked rather than the file as a whole.** Against +the compiler as it stood, `00owned_literals.ts` returns 3 where 10 is due, 1 where 7 is, 7 where 8 +is — six of its seven cases wrong, the seventh being a deliberate control that must pass either +way. A single overwrite is not enough to bite; what bites is one value reaching two slots that are +both later overwritten, whether that is two literals sharing it or one literal holding it twice. + +The verifier is again unchanged — same two files, same six sites. It tracks `ts.RetainSlot` and +`ts.ReleaseSlot`, and this step adds neither; the value-form `ts.Retain` is outside what it pairs. +That is a real limit rather than a clean bill of health, and it is worth saying plainly: the check +that would have caught this bug is not the one that exists. A verifier that pairs a construction +site's retain against the owning block's eventual release needs to reason about the block, not +about a slot in a frame, and nothing here does that yet. + +Full release suite green: 887/887. + +### 9.22 Step 5f: the array-mutating ops + +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 +§9.21 none of them took a reference to what they inserted, while the block goes on releasing +every element it holds when it dies. Same bug, same recipe to expose it — one value reaching two +slots that are both later overwritten — and the same fix: retain each inserted value, in +`MLIRCustomMethods` where the three ops are built. + +**This also closes the spread literal §9.21 left open.** `[...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. That is the whole of +what §9.21 deferred on the array side. + +**`pop` and `shift` get no counterpart, and that is a decision rather than an omission.** The +block does not release the element it gives up — the size shrinks past the slot, so the release +routine (`buildArrayBody`, which loops to `size`) never reaches it. The reference the block held +simply transfers to the returned value. That leaves the result carrying the same "+1 nobody has +consumed" that every freshly produced value already carries, which is the convention §9.12 chose +and §5h removes wholesale. Pairing a release here instead would free a value the caller is about +to use. So the question §9.20 flagged — what a `pop` and a `return` owe each other — turns out to +be already answered by the existing convention, and needs nothing of its own until the slack goes. + +> **Closed by §9.74**, from `LowerToLLVM` as predicted here, and the clamp it needed turned up a +> pre-existing crash plus an unrelated over-release (5aq). + +**Still open, and bounded.** What `splice` *deletes* is memmoved over and its references dropped +without a release. That leaks rather than over-releases, so it is inert; 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 a retain or release emitted from `LowerToLLVM` +rather than from 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 under §9.21 it already holds a legitimate retained reference, and that one +extra absorbed the second release. The case was worthless as written and looked fine. Overwriting +the source as well spends the literal's own reference and puts the two spread copies back on the +hook for theirs — 6 where 13 is due, on the compiler as it stood. The habit that caught it is the +one from §9.21: check each case against the unfixed compiler, not the file as a whole. + +The verifier is unchanged again — same two files, same six sites — and for the same structural +reason as §9.21: these are value-form `ts.Retain`s against a block's eventual release, which is +not the pairing it tracks. + +New test: `test/tester/tests/00owned_array_ops.ts`, all three models. push, unshift and +splice-insert each sharing a value between two arrays; one array pushed twice with the same value; +the spread literal; `pop` and `shift` as run-path coverage of the transfer; and a single overwrite +after a push as a control. + +Full release suite green: 891/891. + +### 9.23 Step 5g: inline records — and why arguments and returns needed nothing + +Three things were queued for this step: arguments, returns, and the inline-record cases §9.19 and +§9.21 deferred. Checking each before writing anything turned two of the three into no-ops, and +the third into a live over-release. + +**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 callee borrows for the +duration of the call and the caller's own reference keeps it alive. The hazard worth testing is a +callee that drops every holder of what it was handed, so the sharpest available 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. It has to: every holder that drops +also retained when it took, so the count cannot fall below the number of live holders. Nothing to +do here now; the convention becomes load-bearing at 5h. + +**Returns already work, for a reason worth naming.** `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, unconsumed. That is exactly the +1 transfer `pop` and `shift` perform in §9.22, arrived +at from the other direction. Verified through two frames. This is also precisely what 5h has to be +careful about: once the birth reference is consumed by the local's retain, that scope-exit release +becomes the last one and would free the value before it is returned. + +**The inline-record case, however, was an over-release, and the reasoning that deferred it was +half wrong.** §9.19 excluded a field of a record held inline because "retaining into a record +nothing releases would leak". The half that does not hold is that an owned local holding a record +*does* release its fields: `ts.RetainSlot` and `ts.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: + +```ts +let x = new Leaf(1); +{ + let a = { item: new Leaf(9) }; + let b = { item: new Leaf(9) }; + a.item = x; // no retain + b.item = x; // no retain +} // both locals release x at scope exit: 2 -> 1 -> 0, freed +print(x.n); // 0 +``` + +**The rule is conditional, unlike the class one.** A class or object field always owns, because +the instance is a heap block whose release routine always runs over its fields. 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 too was checked rather than assumed.** A literal is built +in scratch storage nobody owns and then copied into the owned local, whose `RetainSlot` retains +the fields on the way in. That balances, which also closes the unboxed-object-literal item §9.21 +left open — it was never broken, only unexamined. + +**Two of the six test cases had to be reshaped after failing to bite**, the same way §9.22's +spread case did. `recordsInsideAnArray` cannot bite yet at all: the releases would come from the +array's own release routine, and that never runs while its data block still carries an unconsumed +birth reference. It is kept, labelled as coverage of the predicate's element/record 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. Both were found by running +each case against the unfixed compiler individually — three slices running, three times this has +caught a case that passed either way. + +The verifier is unchanged once more, same two files and six sites. + +New test: `test/tester/tests/00owned_inline_records.ts`, all three models. + +Full release suite green: 895/895. + +### 9.24 Step 5h, first half: allocations are born unowned, and every function returns +1 + +Two things happened here. One is the change; the other is that two comments in the tree were +wrong and cost real time before the change could even be designed, which is worth recording +because both were the kind of stale note that reads as authoritative. + +**The wrong comments.** `Defines.h` said of the header word "the word is not yet initialized on +allocation - nothing maintains a count". That stopped being true at §9.6. Reading it, and the +sibling note in `getHeapBlockHeaderSize` claiming class instances bypass the header through +`GC_malloc_explicitly_typed`, led to an hour of reasoning from a model in which blocks were born +at zero and none of §9.12-§9.23's arithmetic held. `_MemoryAlloc` settles it in one line — it +stored `1`, with the comment "the block starts owned by exactly one reference" — and the typed +path that would have bypassed the header sits behind `ENABLE_TYPED_GC` and was retired in §9.2. +Both comments are now corrected. The lesson is narrow and practical: in this area, read the +emitting code, not the note describing it. + +**The change.** `_MemoryAlloc` now writes **0**. 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 §9.12 deliberately left, and every insertion point that had to exist before it could +come out now does (§9.19-§9.23). + +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, which 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. Once +allocations are born unowned that release is the last one, so `return x` after `let x = new C()` +would free the value on the way out. The value is therefore retained before the scope exit. +Retaining the value rather than trying to identify which local holds it is what makes this work +for `return h.item`, `return arr[0]` and `return cond ? a : b` alike — and it establishes a +uniform convention: **every function returns +1**, the same transfer `pop` and `shift` perform. + +**What this does and does not achieve, stated exactly.** For arrays, strings and boxed object +literals it is a real removal of the slack: `let a = [1, 2, 3]` now takes its data block to one +and back to zero, and the block is freed. For **class instances it is currently neutral**, and +that was verified rather than assumed. `new C()` is a call to a compiler-generated `C..new`, so +the return retain applies to it too and hands back exactly the +1 the birth reference used to +provide. The check was the release-before-retain swap from §9.20: if class instances had lost +their slack, that swap would now free live memory and the owned-* tests would fail. All 35 still +pass, so they have not gained teeth yet. + +**Which names the second half precisely.** The convention is now uniform - calls hand out +1 - so +what remains is for the receiving sites to *consume* it: a local declaration, a field or element +store, a literal capture or a push whose incoming value is already +1 should not retain again. +The classification fails safe in the direction of not knowing: an unrecognised producer is treated +as +0, retained, and leaks. + +> **Correction, made while implementing §9.25.** This paragraph originally listed +> `ts.CreateArray`, `ts.New`, `ts.ArrayPop` and `ts.ArrayShift` as +1 producers alongside calls. +> That was carried over from the model in which allocations were born at one. They are not: +> once a block starts unowned, `ts.CreateArray` and `ts.New` hand back a value at **zero**, and +> their receiver's retain is exactly right. Only a call that retained on the way out, and +> `pop`/`shift` transferring a reference the data block held, are genuinely +1. + +The dangerous direction is the opposite one, and it has a specific name: a call that returns a +heap value **without** passing through the return path patched here - a runtime or builtin helper +such as string concatenation, or a function imported from a module built before this convention. +Treating those as +1 would skip a retain that was never performed and free live memory. So the +second half cannot simply say "calls are +1"; it has to distinguish a user function with a +generated return from an external one. That is the next slice, and it is the first in this arc +where the failure mode is a premature free rather than a leak. + +Full release suite green: 895/895. + +### 9.25 Step 5i: consuming the transferred reference, and the tests finally bite + +§9.24 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 still leaking on every program, and in doing so it is the first slice where the +counting is load-bearing rather than slack. + +**First, a correction to §9.24's own list of producers.** That section named `ts.CreateArray`, +`ts.New`, `ts.ArrayPop` and `ts.ArrayShift` as +1 alongside calls. That was written from the old +model. Once allocations are born unowned, a freshly allocated block is at **zero**, so +`ts.CreateArray` and `ts.New` produce +0 and their receiver's retain is exactly right. What is +genuinely +1 is narrower: a call that retained its result on the way out, and `pop`/`shift`, which +hand over a reference the data block was holding. + +**What is marked, and what deliberately is not.** Only `new C()` is marked here, at the one place +that builds the call and therefore knows the callee is the generated `C..new` - which goes through +the retaining return path. Nothing infers ownership from an operation merely being a call. 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 in fact owned only leaks, so the unknown case falls the safe way. + +**The four receivers all consume**: a local declaration, a field or element store, a literal +capturing a value, and `push`/`unshift`/`splice`. The release side is untouched in every case - +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 "released but never retained" 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, and the two known +throwing-`[Symbol.dispose]()` findings are back. Third time this arc that a new insertion point +taught the verifier a new shape, and the first time the symptom was silence rather than noise. + +**The tests have teeth now, and this is the milestone §9.19 was waiting for.** Re-running the +release-before-retain swap: before this slice it failed nothing; now it fails +`test-jit-rc-owned-locals`, `test-jit-rc-owned-fields` and `test-jit-rc-owned-elements`. +`00owned_fields.ts` was written at §9.19 with a header explaining that it guarded shape and run +path but not counting, and asking for exactly this experiment to be re-run once the slack went. +It now catches the bug it was written for. + +**Still leaking, and now the whole of what is left.** Every +1 that is not consumed: the result of +an ordinary function call assigned anywhere (`let y = f()` retains a value `f` already retained), +a discarded `pop`, and a returned value the caller drops. Closing those needs the producer +classification to extend past `new`, which is the risky work this slice deliberately did not do. + +Full release suite green: 895/895. Verifier: two files, six sites, unchanged. + +### 9.26 Step 5j: the transfers that can be settled, and the call that cannot + +5j was meant to extend the producer classification past `new` to ordinary calls. Half of it is +here; the other half turned out to need a different shape than "one more marking site", and this +section records why rather than shipping a heuristic for it. + +**What landed: `pop` and `shift`.** These are the compiler's own operations with known semantics, +so there is nothing to classify. The data block gives up the element without releasing it - the +size shrinks past the slot, so its release routine never reaches it again - which hands the +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 now one owner rather +than two. + +**What did not, and the specific reason.** Every function retains its result on the way out +(§9.24), 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* callee retains, and three separate things +stop that being answerable where the call is generated: + +- **Not every return path retains.** The retain sits in the return *statement*, but a concise + arrow body (`() => expr`) reaches `mlirGenReturnValue` down a different path, and so does + `yield`. Marking a function whose body takes one of those would consume a reference nobody + took. +- **The callee may not exist yet.** MLIRGen emits `ts.CallIndirect` on a symbol reference; the + callee's `FuncOp` need not have been created when the call site is generated, so a lookup would + answer differently depending on declaration order. Always-safe, since the unknown case falls to + +0 and leaks — but silently order-dependent, which is worse than not doing it. +- **External callees look identical.** A `declare`d function, one imported through `__decls`, or + a runtime helper has no retaining return at all. These are the cases where being wrong frees + live memory. + +The shape that answers all three is a pass after MLIRGen, when every `FuncOp` is present and each +one's return paths can be inspected rather than predicted. That is a different piece of work from +the marking sites of §9.25, and it is the right place to stop this slice. + +**A test that was worthless, caught by the habit rather than by luck.** The first version of +`00owned_transfer.ts` 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. This is the same trap as §9.24's memory measurement: an experiment that confirms what you +expected is worth less than one you tried to break. + +Worth recording separately: injecting the *opposite* mistake - treating every `ts.Load` result as +already-owned, so receivers stop retaining - fails six of the ownership tests. The suite does +detect premature frees broadly now, which is the property that matters most from here on. + +Full release suite green: 899/899. Verifier: two files, unchanged. + +### 9.27 Step 5k: consuming an ordinary call's result + +The dominant remaining leak, and the first piece of this arc that is a pass rather than a marking +site. §9.26 gave the reason: deciding whether *this* callee retains its result cannot be settled +where MLIRGen builds the call. All three obstacles it named dissolve once every function exists, +so the work moves to a module pass that runs straight after MLIRGen. + +**It looks rather than predicts.** A function counts as returning owned only when every +`ts.ReturnVal` of a heap-owning value in it is preceded by a `ts.Retain` of *that same value* in +the same block. Anything else is left alone: a callee with no body, a call through a function +value with no single callee to inspect, a generator (vetoed outright, since what its caller +receives is the generator object rather than anything those returns produce). Those callers keep +retaining, which leaks rather than freeing something live. The whole design puts the uncertain +case on the leaking side. + +Each exclusion was checked on emitted IR rather than assumed: a `declare`d callee keeps its +`ts.RetainSlot`, an indirect call through a parameter keeps its own, and a local function's call +is marked and its receiver's retain removed - with the declaration marked as the acquisition so +the verifier can still pair the release that follows. + +**A prerequisite that had to land first.** A concise arrow body (`() => expr`) returns without +going through the return statement, so it never got §9.24's retain and would have been excluded +from the convention entirely. It has one now. This is not fixing a dangling read - there is no +scope exit there to free anything - it is the convention itself: callers cannot be told "calls +return owned" while one shape of function quietly returns borrowed. + +**What the check actually excludes, measured rather than guessed.** Removing it raises the number +of marked call sites across the suite from **469 to 497**, so it is not a formality - it excludes +28 real calls, reaching 92 distinct functions. Following one of them to its IR explains all of +them: the retain is emitted on the value the return statement *evaluated*, while +`mlirGenReturnValue` then casts that value to the declared return type, and it is the cast result +that `ts.ReturnVal` carries. So a return needing a cast retains the wrong value. It is benign - +the reference lands on a value nobody releases, which leaks - and the pass is right to exclude +those functions, but the fix is to apply the return-type cast before the retain rather than +inside the return. That is a separate slice. + +**An honest coverage note.** Disabling the retain check entirely - marking every function with an +owning return, sound or not - still leaves the suite at 903/903. The guard is reasoned rather than +test-validated, because no test currently exercises a callee that returns a heap value without +retaining it. The *other* direction is covered: over-consuming a call result, by removing every +receiver retain instead of one, segfaults `00owned_call_results.ts` outright. + +New test: `test/tester/tests/00owned_call_results.ts`, all three models - a result outliving the +local that received it, shared between two locals, stored into a field, captured by an array +literal and by `push`, forwarded through two frames, returned from a method, and returned from an +arrow function. Each calls `churn()` between the last release and the read, for the reason §9.26 +learned the hard way. + +Full release suite green: 903/903. Verifier: two files, unchanged. + +### 9.28 Step 6: the allocator flips, and the first measurement that means anything + +`needsGCRuntime()` returned true for `rc` from §9.6 onward, so under `-mm=rc` Boehm was still +allocating and still collecting behind the counts. That was the right call while the insertion +points were being built one at a time - a missing release stayed an inert leak - but it meant no +memory number taken under `rc` said anything about reference counting. The predicate now names +exactly one model, `gc`. Under `rc` the program allocates from `malloc`, frees through `free`, and +links no libgc; what the counts miss now leaks, and shows. + +**What the flip cost: one crash, and it was not RC's.** `test-jit-rc-disposable-scopes` failed +immediately - and the same file failed under `-mm=none` too, on a build with none of this work in +it. A `using` inside a `catch` clause, at `-O3`, on Win64. Narrowed to a bare `new` inside a +handler whose result is used there. + +The chain: a handler is its own funclet, and every call inside one has to carry a `funclet` +operand bundle naming its pad. `Win32ExceptionPass` stamps them correctly - verified on emitted IR +at `--opt --opt_level=0`, where the allocation and its zero-fill both carry the bundle. LLVM then +rewrites `malloc` + `memset(0)` into `calloc`, and builds the replacement **without carrying the +operand bundles over**. WinEHPrepare stops seeing the instructions after it as part of the +funclet, and the handler is emitted as a bare prologue: no body, no `catchret`. It faults the +moment it runs. + +Each step was checked rather than reasoned about. Stock `opt -O3` on our own pre-optimisation IR +reproduces the dropped bundle, so the defect is LLVM's, not the pass's. `llc` on that output shows +the empty funclet directly; hand-adding the bundle back to the `calloc` restores the full handler +body and its `catchret`. `-print-after-all` names the pass: **DSE's `tryFoldIntoCalloc`**, not +SimplifyLibCalls, which is why emitting the zero-fill as `llvm.memset` rather than a `memset` call +fixed `none` but left `rc` still crashing. + +**The fix is to ask for what we mean.** A zeroed block is now requested as `calloc` outright, so +there is no pair left for that fold to rewrite; GCPass maps it onto `GC_malloc` - rewritten rather +than renamed, since the arity differs - and drops the now-unreferenced declaration. The wasm fork +has no `ts_calloc` and no Win64 funclets, so it keeps the two-step form, as the intrinsic. + +Only `gc` was ever safe here, and by accident: GCPass deletes the zero-fill, so the pattern the +fold looks for never survived to LLVM. That is why the new test needs its non-`gc` variants to be +worth anything, and the teeth were confirmed the usual way - with the fix disabled and rebuilt, +`00alloc_in_catch.ts` faults under `-mm=rc`. + +**Found and left alone:** a try/catch nested *inside* a catch clause crashes with no allocation in +it at all, in every memory model and at every optimisation level. Unrelated to this, and older +than it; the case is called out in `00alloc_in_catch.ts` rather than covered by it. + +**The measurement, at last.** Peak working set, AOT executables: + +| program | gc | rc | none | +|---|---|---|---| +| allocation churn, 1M iterations, `-O0` | 4.2 MB | **3.8 MB** | 172.8 MB | +| `raytrace.ts`, `-O3` | 4.1 MB | **129.5 MB** | 106.3 MB | + +The first line is what this whole arc was for: a value bound to an owned local is allocated, +released and reclaimed a million times over, flat, without a collector - marginally below Boehm. +(At `-O3` that loop vanishes in all three models, allocations and release calls together, which is +its own small piece of good news about the generated code.) + +The second line is the honest other half. `raytrace.ts` reclaims **nothing**: it is built almost +entirely out of `return new Vector(...)` used inline - `Vector.plus(Vector.times(k, a), b)` - so +every intermediate is a call result passed straight as an argument and never bound to any slot. +Each carries the +1 its return retained (§9.24) with no owner to give it back. That is item 5l, +discarded temporaries, and this measurement reclassifies it: not a nicety at the end of the list +but the dominant leak in ordinary expression-shaped code. `rc` sitting *above* `none` is the same +story seen from the other side - the release calls are uses, so fewer dead allocations get +optimised away, and nothing is reclaimed to pay for it. + +New tests: `00alloc_in_catch.ts` in all four variants, plus `-mm=none` variants of `03disposable.ts` +and `04disposable.ts` - the file that caught this had no non-`gc` coverage of its own. + +Full release suite green: 909/909. Verifier: two files, unchanged. + +### 9.29 A `try`/`catch` inside a `catch` clause + +Not RC's, and older than any of this - §9.28 only found it because a test written for that step +tried to allocate inside a nested handler. It crashed in every memory model, at every optimisation +level, with nothing allocated in it at all. A try nested in a try *body* or in a `finally` always +worked; only the catch clause was affected, which is why nothing had caught it. + +Two independent bugs, the second visible only once the first was fixed. Both are confirmed +individually load-bearing by disabling each alone and rebuilding. + +**1. The catch-variable search descended into the nested try.** `TryOpLowering` finds its clause's +`ts.CatchOp` by walking the catches region, and the walk went straight through a nested `ts.TryOp` +into that try's own catches. It picked up the *inner* clause's catch, so the outer try's landing +pad got its RTTI type filter from the wrong clause. The debug build says this outright - the +`assert(!catchOpPtr)` on the second catch found - which is worth remembering: the release build +faulted with no diagnostic at all and no usable stack, and the debug build named the line in one +run without a debugger. The walk is now pre-order and skips a nested try, because `skip()` only +prunes regions still to come and the default post-order has already visited them. + +**2. A catch clause can be ended twice over.** §9.14 has a `throw` leaving a catch clause end that +catch ahead of itself. A nested `try`'s throw is such a throw, so the enclosing clause is already +ended by the time the outer try emits its own end-of-catch marker - and the surplus marker became +the region's `end` instruction, which is where the catchret goes, so it survived into the emitted +code. `__cxa_end_catch` is an Itanium marker with no Win64 counterpart, so it failed to link +(`Symbols not found: [ __cxa_end_catch ]`). Win32ExceptionPass now skips past end-of-catch markers +while looking for a region's end and removes them, which handles any number of them, and removes +an unclaimed one found with no region open at all. + +New test `test/tester/tests/00nested_catch.ts`, four variants: a catch in a catch, three levels +deep, an inner clause that is never taken, a nested try with a `finally` of its own, the whole +thing inside a loop, an inner clause that throws past the outer one, and the try-in-body and +try-in-finally shapes that always worked, kept alongside so a fix here cannot quietly break them. +`00alloc_in_catch.ts` regained the nested case it had to leave out. + +**The direct test of bug 1 is a type test, not a value test.** `outerFilterIsItsOwn` throws a +string caught by the outer clause and an int caught by the inner, so an outer pad carrying the +inner clause's filter would not catch the string at all. + +**Found while writing these, and deliberately NOT fixed: reading a catch variable's value is +broken on its own.** No nesting involved. `try { throw 2 } catch (v: int) { t = v }` reads 0 rather +than 2 - but only in a module that throws just that one type; adding the other clauses of +`00try_catch.ts` to the same file makes it read correctly, which is why that test passes and this +went unnoticed. Reproduced in every model, and at `-O3` a separate variant of the same shape reads +0 where `-O0` reads 3. `00nested_catch.ts` therefore checks which clause runs and in what order and +never reads a catch value; nothing there should be made to depend on a broken feature. A third bug, +in the same subsystem, still open. + +> **Open, and re-diagnosed in §9.65 as 5am.** Neither half of the description above survives. It +> does not read 0 - it reads uninitialised memory, three runs of one binary giving 134, 131, 184 - +> and it has nothing to do with how many types the module throws. It is **JIT-only**: every case +> tried is correct ahead of time. So the rule this paragraph sets is too strong; a catch-value +> assertion in the AOT tier is a real test, and the tier already runs every corpus file. + +Full release suite green: 913/913. + +### 9.30 Step 5l: giving back the temporaries + +§9.28's measurement made this the priority: `raytrace.ts` reclaimed **nothing** under `-mm=rc`. +It is built almost entirely out of `Vector.plus(Vector.times(k, a), b)`, so nearly every +allocation it makes is an intermediate passed straight as an argument and never bound to +anything - carrying the +1 its return retained (§9.24) with no owner to give it back. + +**Consumption is now recorded rather than implied.** Every receiving site (§9.25) answered +`producesOwnedReference` by *not* emitting a retain, which left no trace: after §9.27 erased a +receiver's retain, a consumed call and a call nobody received looked identical. A second +attribute, `OWNED_RESULT_CONSUMED_ATTR_NAME`, is set wherever a receiver takes a reference over, +and its absence is what identifies a discarded temporary. + +**The release goes at the end of the producer's own block.** That is a temporary's natural +lifetime - the enclosing statement, or one iteration of a loop body - and, more to the point, it +is unconditionally after every use in that block. Placing it after the last *user* looks tighter +and is wrong: the receiver of `let x = f()` retains the result of the **cast**, not of the +call, so the call's last user is the cast and a release put there runs before that retain and +frees the value out from under it. Two shapes are refused and left leaking as before: a user +outside the producer's block, and a user that is a terminator (a value handed to a successor as a +block argument is still live past the release point). + +*Amended at §9.33: "the end of the block" was taken literally, and `ts.ReturnVal` is not a +terminator - so in any function ending with a `return` the release landed after it, and the +affine lowering dropped it as unreachable. 36 of `raytrace`'s 47 releases never ran. The rule is +now "before the first op that ends the block's execution", which is what MLIRGen's own scope exit +had been doing all along.* + +**A second gap had to close before any of this fired.** §9.27 classified a function as returning +owned only when every return was preceded by a `ts.Retain`. But there are two ways to hand back a +reference: retain one, or forward one already held - and `return new C()` consumes the instance's +own +1 rather than adding a second (§9.25), so there is no retain to find. Every +`static times(...) { return new Vector(...) }` was therefore unclassified, which is most of what +expression-shaped code is built from. A return whose value comes from an `OWNED_RESULT` operation +now counts too. + +**What it reclaims**, peak working set, AOT: + +| program | gc | rc before | rc after | none | +|---|---|---|---|---| +| `raytrace.ts`, `-O3` | 4.1 MB | 129.5 MB | **79.3 MB** | 106.7 MB | +| nested call temporaries, 500k iterations, `-O0` | 4.2 MB | — | **3.8 MB** | 188.0 MB | +| object literal returned as an interface, `-O0` | 4.2 MB | — | 41.5 MB | 42.3 MB | + +The second line is the shape this step is about, and it is now flat. The third is what `raytrace` +still leaks: an **object literal returned through an interface** reclaims essentially nothing. +Arrays and strings were checked the same way and both reclaim (3.9 MB against 42.6, and 3.8 MB +against 27.2), so the remaining leak is specific to the literal/interface path - the boxed literal +or the clone an interface cast makes - and is the next thing to look at, not another temporaries +problem. + +*Read with §9.31: it was the boxed literal, an interface owned nothing at all - and the 79.3 MB +above was partly memory freed while still referenced, so it is not a figure to compare against.* + +New test `test/tester/tests/00owned_temporaries.ts`, four variants. **Teeth, measured per case +with two separate probes** rather than assumed: + +- releasing consumed results as well as discarded ones is caught only by the loop case, because + an end-of-block release lands after every read in that block - a useful reminder that this + particular perturbation cannot reach most of the file; +- releasing immediately after the producer instead of at end of block - the ordering the whole + design rests on - is caught by 4 of the 8 cases at `-O3` and by 6 of 8 at `-O0`. + +Two cases cannot fail loudly and are kept knowingly: `discardedResult` has nothing that reads the +discarded values, and `temporaryKeptByCallee` is balanced by push's own retain either way. The +first version of `usedAsArgumentAndBound` passed under both probes for the wrong reason - a freed +block still held its old value - so `argumentReadAfterCalleeAllocates` was added, where the callee +allocates before reading its arguments and a block freed early is overwritten before the read. + +Full release suite green: 917/917. + +### 9.31 Step 5n: an interface is an owner + +`MLIRTypeHelper::ownsHeapMemory` answered no for `InterfaceType`, with a reason that was true as +far as it went: an interface type carries only a name, so the layout behind its `this` pointer is +not recoverable from the type. Nothing an interface held was ever released - not the block a +literal is boxed into, not the class instance behind a cast - which is where `raytrace`'s +remaining leak sat (§9.30). + +The layout does not have to be recoverable from the type, because **an interface has exactly the +problem an `any` box has, and the same answer was already in the tree**. An interface value grows +a third word holding the runtime type tag of whatever `this` points at (`INTERFACE_TYPE_INDEX`), +and its release and retain routines read that tag's descriptor and call the concrete type's own +routines - `releaseViaDescriptor`/`retainViaDescriptor` unchanged from what §9.6 built for `any` +and for tagged unions. Two properties make the tag safe to rely on: + +- **every interface value in the program is built by one op.** A cast from a class, a cast from + an object literal, and even `null`/`undefined` as an interface all reach `ts.NewInterface` + (`CastLogicHelper`), so there is no path that leaves the slot undefined. Where `this` owns + nothing the tag is null, which states "nothing to release" positively, exactly as a null + descriptor slot does. +- **descriptors are keyed by the concrete type, not by the `typeof` name.** Every object literal + reports the name "object"; `getOrCreateTypeDescriptorName` already hashes the type into the + symbol, so two literals of different shapes get their own records. + +The interface value going from 16 to 24 bytes is why `-mm=none`'s numbers in this section are +about 8% above §9.30's. + +**Two dormant omissions became live over-releases the moment an interface became an owner, and +both were freeing memory that was still in use.** Neither is a consequence of this slice; both +were reachable before it and simply had nothing that walked the block. + +1. **`castTupleToInterface` allocates a block and fills it from a literal without retaining what + the literal holds** - the debt §9.21 describes, which the object-literal boxing path + (`MLIRGenExpressions`) pays and this one did not. `{ start: pos, dir: rd }` cast to an + interface produced a block holding two `Vector` references it had never taken. +2. **`retainInsertedElements` skipped its retain for an already-owned value without recording the + consumption** - the one receiving site that did not. Once §9.30 began releasing what nothing + consumed, `arr.push(new C())` released the instance at the end of the pushing block, freeing + an element the array still held. + +Both are invisible for as long as the block that builds is also the block that reads, since +§9.30's release goes at the end of the producing block - which is why §9.30's own tests missed +them. `function add() { store.push({ v: new Vec(42) }) }` read from `main` prints `999` on the +commit before this one and `42` after it. + +**Item 5m, cast before retain, is done here** because without it this slice does not fire. A +return retains the value the return expression evaluated, and `mlirGenReturnValue` then casts - +so `return { start: p, dir: d }` against a declared interface retained the *tuple*, and the heap +block the cast allocates got no reference at all. The cast now happens first +(`castToDeclaredReturnType`), before both the retain and the scope exit. + +That surfaced a **placement bug in §9.30 that had nothing to do with interfaces**: a discarded +temporary is released at the end of its block, and in a generator that block can contain a +`ts.StateLabel` - a resume point the state machine re-enters. The end of the block is then +reachable on a path that never ran the op producing the value, which appears as a dominance +failure in the affine lowering (`00iterator_bug.ts`) rather than as anything the pass could see +while the generator was still one block. `allUsesReleasableInOwnBlock` now refuses a value with a +state label after its definition. + +**Measured, AOT peak working set:** + +| shape | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| literal boxed as an interface, bound in a loop | 4.2 | **4.2** | 42.6 | +| interface temporary passed as an argument (`raytrace`'s shape) | 4.6 | **3.8** | 114.6 | +| instance method returning a class | 4.2 | 31.9 | 31.9 | +| `raytrace.ts`, `-O3` | 4.6 | 114.2 | 114.5 | + +The shapes this slice is about are now flat. **`raytrace` is not, and its number is worse than +§9.30's 79.3 MB - which was not a real number.** The over-release in (1) above was freeing +`Vector`s that boxed `Ray` literals still pointed at, and memory that is handed back while still +referenced counts as reclaimed. Correctness cost 35 MB here, and the honest reading of §9.30's +figure is that part of it was never earned. + +What `raytrace` leaks now is item 5o below: it is built out of **instance-method** calls, and +§9.27 classifies a callee only when the call names it through a `ts.SymbolRef`. A method reached +through `GetMethod`/a vtable answers empty and is left alone, so its result's +1 is never +consumed - visible on its own as `rc` and `none` both at 31.9 MB in the table above. + +New test `test/tester/tests/00owned_interfaces.ts`, four variants, eight cases, each building in +one block and reading in another with `churn()` between - the arrangement §9.30's tests lacked +and the reason those two over-releases survived it. **Teeth measured per fix**: releasing the +interface immediately after construction, dropping the boxing retain, and dropping the push +consumption marking each break the test at both `-O0` and `-O3`. + +Full release suite green: 921/921. Ownership verifier unchanged at its two standing findings. + +**Found and not fixed:** `--di --opt_level=0` fails to emit LLVM IR for any reference-counted +program ("DISubprogram attached to more than one function") - the generated `tsrel_`/`tsret_` +routines inherit the debug scope current when they were generated. Pre-existing, reproduces on +`00owned_temporaries.ts` and `00interface.ts`, and on no test-suite variant. + +> **Fixed in §9.69.** The diagnosis above is right as far as it goes; the scope is also +> inherited by block arguments and hidden inside `NameLoc`s, which is what made it four attempts +> rather than one. 453 of 453 corpus files now emit IR under `-mm=rc --di`. "Any reference-counted +> program" above is very slightly too strong - one that allocates nothing generates no ownership +> routine and always built. + +### 9.32 Step 5o: what a method call names, and what it does not + +§9.31 measured an instance method's result reclaiming nothing and filed 5o to fix it. The fix +that landed is much smaller than the item, and the reason is worth recording, as is the thing +that turned out to be the actual leak. + +**The half that is safe.** `calleeNameOf` resolved a callee only through `ts.SymbolRef`, so it saw +a plain function and nothing else. A method call is not shaped like that: `obj.m(x)` builds a +bound function and splits it apart again, `GetMethod` for the code and `GetThis` for the receiver, +so the callee sits a step further back. It now looks through that, for exactly the chains the +dialect's own canonicalizer (`SimplifyIndirectCallWithKnownCallee`) already rewrites into direct +calls — `ts.ThisSymbolRef`, and `GetMethod` over either a `ts.ThisSymbolRef` or a +`CreateBoundFunctionOp` naming a function. That those chains name one callee is not a new +judgement; it is the one the canonicalizer has been making all along. + +**The half that pays, and does not hold.** That safe half is worth almost nothing here, because +an ordinary instance method does *not* take it. Of `raytrace`'s method references, 32 are +`ts.ThisSymbolRef` — every one a constructor, so `void`, so nothing to own — and the 9 that +return values are all `ts.ThisVirtualSymbolRef`. A method on a class with a vtable is dispatched +through it whether or not anything overrides it. + +A virtual call's identifier names the declaration the call was written against, not what the +runtime class put in the slot, so reading it as the callee would consume a reference an override +may never have taken — the one mistake in this arc that frees live memory rather than leaking. +`private` looks like it settles that: TypeScript forbids overriding a private member, all 9 of +`raytrace`'s sites are private, and marking them classified took the file from 114.2 MB to +111.2 MB with the suite green. + +**It was reverted, because the guarantee is not true of this compiler.** This program: + +```ts +class Base { private tag(): number { return 1; } public get(): number { return this.tag(); } } +class Derived extends Base { private tag(): number { return 2; } } +``` + +is rejected by TypeScript and accepted here, and `new Derived().get()` prints `2` — the +redeclared private method takes the base's vtable slot and overrides it. So `private` is not a +single-target property in tslang, and a rule resting on it would have been sound only by +accident. Making it sound needs the callee's whole override set, which is a hierarchy question +the pass cannot see and MLIRGen cannot close cross-module; that is 5o's real cost, and it buys +2.6% of `raytrace`. + +**What is actually left is not method dispatch at all.** With interfaces owning (§9.31) and +static calls classified (§9.27), the remaining allocation in `raytrace` is the **capture box of a +closure**. `ownsHeapMemory` names function types among the deliberate exclusions, for a reason as +true as the interface one was: the box is heap-allocated but its type does not appear in the +function type, so there is nothing in the type to walk. `getNaturalColor` builds `addLight` per +ray, and nothing ever gives that box back. + +| shape | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| closure created per call, in a loop | 4.2 | **76.8** | 77.8 | +| private instance method returning a class | 4.2 | 31.9 | 31.9 | +| `raytrace.ts`, `-O3` | 4.6 | 113.8 | 114.5 | + +That is the same shape of problem §9.31 solved, and the same answer is available: an interface +could not name what was behind its `this` either, and now carries a tag beside it. A closure +value is a pair the same way an interface is. Filed as 5p, and it is where the next slice should +go rather than 5o. + +Full release suite green: 921/921. + +### 9.33 Step 5p: a closure owns its capture box — and the release that never ran + +Two things landed together here. The second was found while measuring the first, is much larger +than it, and is not about closures at all. + +**A closure's capture box.** `ownsHeapMemory` excluded function types for a reason with exactly +the shape the interface exclusion had (§9.31): the box is heap-allocated but its type appears +nowhere in the function type. So the same answer applies. A bound or hybrid function value grows +a third word carrying the runtime type tag of its `this` (`CLOSURE_TYPE_INDEX`), and release and +retain go through it — `releaseViaTagBesideThis` is now shared by both cases, which differ only +in which slot the tag sits in. + +The tag is what separates the two kinds of value that share this representation. **A closure owns +its capture box; a bound method's `this` is an object that belongs to somebody else**, and +`obj.m` must not take a reference to `obj`. Only the closure built over captured variables is +marked (`OWNS_CAPTURE_ATTR_NAME`, set where MLIRGen knows the difference); everything else carries +a null tag and its release costs a call that does nothing. As with `NewInterface`, one op builds +every such value, so no path leaves the slot undefined. + +**The release that never ran.** Measuring that change showed no movement at all, and the reason +was not in the closure work: + +> **`ts.ReturnVal` is not a terminator.** The scope-exit releases and `ts.Exit` follow it in the +> same block. But the affine lowering turns it into a branch to the exit block, so anything +> appended *after* it is dropped as unreachable — and §9.30 appends its releases at the end of the +> block. + +Any function whose block ends with a `return` has that shape, which is most of them. **In +`raytrace.ts`, 36 of 47 releases were being discarded on the way to affine**: §9.30 had been +largely inert for exactly the code it was written for, and the tests never saw it because a +release that vanishes only leaks. MLIRGen's own scope-exit releases never had the problem — it +emits them before building the return, which is the model the pass now follows: insert before the +first `ts.ReturnVal`/`ts.Return`/`ts.Exit` after the definition, else at end of block. 46 of 47 +survive now. + +Releasing before the return is right for a discarded temporary by definition — what is being +returned was consumed by the return's own retain, so it is not in this set. + +That fix also had to be paid for: it made releases survive in **generators**, where a position +plainly after a definition may not be on the path that reaches it once the state machine cuts the +block at every resume point. Three generator tests failed with dominance errors. §9.30's narrower +`ts.StateLabel` guard is not enough, so whole generators are now excluded from the discarded +release, the same way `functionReturnsOwned` already excludes them and for the same reason. Their +temporaries leak. + +| shape | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| closure created per call, in a loop | 4.2 | **46.9** | 75.1 | +| interface temporary as an argument | 4.2 | 3.8 | 114.6 | +| `raytrace.ts`, `-O3` | 4.2 | **103.4** | 114.5 | + +`raytrace` 113.8 → 103.4, of which the release placement is 4.3 MB and the capture box 6.1 MB. + +**What the closure benchmark still holds is not the box.** It is down from 77.8 MB but not flat, +and the rest is the *captured variables themselves*: a parameter or local that a closure captures +is given a heap cell of its own so the box can point at it, and nothing frees those. Two cells per +call in that benchmark against one box, which is the ratio the number shows. + +The same allocation is behind a **use-after-free that predates all of this**: a captured object +carried out of its frame is released by that frame's own scope exit. + +```ts +function makeAdder(k: number) { let bump = new Vec(k); return (v: Vec) => v.x + bump.x; } +``` + +`makeAdder`'s scope exit releases `bump`, and the returned closure's box still points at it — +`ts.ReleaseSlot` on the captured local, emitted by MLIRGen, nothing to do with this slice. Filed +as 5q. `00owned_closures.ts` deliberately captures a number in its two escaping cases so that this +older bug does not mask what they are there to check. (Closed in §9.34, which also revised what +the cells cost: measured against a benchmark that survives `-O3`, they were the whole of +`raytrace`'s remaining leak, not a share of it.) + +New test `test/tester/tests/00owned_closures.ts`, four variants, six cases. **Teeth**: releasing +the closure immediately after construction breaks it at both `-O0` and `-O3`. Note that +*disabling* the tag does not break it and cannot — that is the leaking direction, which no test +can see. + +Full release suite green: 925/925. Ownership verifier unchanged at its two standing findings. + +### 9.34 Step 5q: a captured variable's cell is owned + +A variable a closure captures by reference does not live in its frame. Its storage is a heap +block of its own — call it a **cell** — so that the frame and every closure over it read and +write the one variable (`ALLOC_CAPTURE_IN_HEAP`, `VariableOpLowering`). The box holds the cell's +address; the frame holds the same address in the slot the variable's name resolves to. + +Nothing owned that cell. The frame's scope exit released the *value in it* and the cell itself +was never freed, which is two failures in one: + +```ts +function makeAdder(k: number) { let bump = new Vec(k); return (v: Vec) => v.x + bump.x; } +``` + +`makeAdder`'s exit freed the `Vec` while the returned closure still pointed at the cell holding +it — a use-after-free older than any of this work — and leaked the cell on the way out. + +**The cell has owners now**, and there are exactly two kinds: the frame that declared the +variable, and each capture box that captured it. So: + +- A cell is **born owned** (`VariableOpLowering`, under `-mm=rc`). That is the opposite of a + value block, which is born unowned because a receiver is about to take it (§9.24) — a cell has + no receiver, and its creator is its first owner. The one heap variable this must *not* apply to + is the box itself, whose `captured = true` means only "allocate in the heap"; `CaptureOpLowering` + marks it `CAPTURE_BOX_ATTR_NAME` so the two cannot be confused. +- **The box takes one too**, at the capture site — and only where a `ts.Variable`/`ts.Param` was + actually marked captured, because that marking is what makes storage a cell. Retaining a stack + slot would write a count into the frame word in front of it. +- **A scope exit releasing a captured local emits `ts.ReleaseCell`**, not `ts.ReleaseSlot`. The + frame is giving up the cell; the value inside it goes when the last owner does. + +`ts.RetainCell` / `ts.ReleaseCell` are new ops for that difference — the existing pair addresses +storage in order to reach the value in it, these address the block *as* the value. Both erase +under a model that is not reference counting, like every other ownership op. + +**The box needed routines of its own.** Its release had been the generic `object>` one, +and that skips a `RefType` field — correctly, everywhere else in the compiler, since a reference +field points at storage its holder does not own. A capture-by-reference field is the exception: +it holds a cell address, and the box co-owns that cell. `getOrCreateCaptureBoxReleaseRoutine` +walks the fields itself, releasing a cell through the field and anything else in place, and is +keyed by the capture's own `ref>` type rather than the box's `object>` so +that neither the routines nor the descriptor can be shared with a plain object of the same shape. + +Writing the cases turned up two more over-releases, both older than this slice and both confirmed +against a rebuilt baseline rather than assumed: + +- **A `const` captured by value** goes into the box as a copy of the reference, and a copy of a + reference is a further owner. Nothing retained it, so `const held = new Vec(15); return () => + held.x` read freed memory. `mlirGenRetainCaptured` at the capture site. +- **Assigning to a captured variable from inside the closure.** Inside the closure the variable + is a load of the box's field, which none of `isOwningSlot`'s cases recognised, so `cur = new + Vec(..)` stored a value nothing had taken — and §9.30 then released it at the end of the block + as a discarded temporary, freeing what the assignment had just set. `isCapturedCellSlot` + recognises the shape: a tuple field whose type is a reference is what a capture by reference + is, and nothing else builds one. + +The ownership verifier had to be told that `ts.ReleaseCell` discharges a slot too — without that +it reported every captured local in the suite as a leak, which is the verifier being right about +its own model and wrong about the program. + +| shape | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| `raytrace.ts`, `-O3` | 4.2 | **2.6** | 114.5 | +| closure over a local, per call, `-O0` | 2.6 | **2.6** | 31.9 | +| closure over a parameter, per call, `-O0` | 2.6 | **22.6** | 2.6 | + +`raytrace` 103.4 → **2.6 MB**, below `gc`'s own 4.2, and the size of that step says what the +cells were: its per-pixel closures (`addLight`, `recenterX`/`recenterY`) declare their captured +variables inside functions called once per pixel, so the leak was a cell per capture per pixel. + +> **Correction, §9.43.** That 2.6 MB is not a measurement of `raytrace`. The program was +> crashing under `rc` from this step until §9.43 fixed the nested-capture retain, and 2.6 MB is +> how far it got before it died - the measuring script never printed an exit code, so nobody +> looked. Every `raytrace` figure in this document from here to §9.42 is that same crash. The +> first real one is in §9.43, and it is 61-68 MB. + +The third row is the remaining hole and is filed as 5r. A parameter is borrowed, so nothing in +the frame releases it, and a captured parameter's cell therefore has an owner that never lets go. +That is a leak and only a leak — the cell outliving everything is exactly what stops the box from +releasing an argument the caller still owns — and it is why the `none` column there is the small +one: with no ownership calls to keep it alive, the whole allocation is optimised away in the other +two models. (§9.35 closes it, and finds that the second row's benchmark was flattering: a +captured *local* leaks its cell too whenever the local's type owns nothing.) + +Five new cases in `00owned_closures.ts`. **Teeth**, each checked by disabling the fix and +rebuilding: removing the box's retain of the cell, and removing the cell's birth reference, each +abort the test. Disabling the box's *release* of the cells does not and cannot — that is the +leaking direction. + +Full release suite green: 925/925. Ownership verifier unchanged at its two standing findings. + +### 9.35 Step 5r: a cell is given back whether or not the frame owns what is in it + +§9.34 gave a cell owners, and released the frame's one at scope exit — but only for the slots +that were already on the scope's list, and that list is `ownedVars`, the list of locals whose +*value* the frame owns. Which of a function's cells got a release therefore turned on a question +that has nothing to do with cells at all. Two shapes fell outside it, and the measurements are +the whole argument: + +| shape, `-O0` | `gc` | `rc` before | `rc` after | `none` | +| --- | --- | --- | --- | --- | +| closure over a `Vec` local | 4.1 | 3.7 | 3.7 | 161.3 | +| closure over a `number` local | 4.1 | **51.7** | **3.7** | 113.0 | +| closure over a `number` parameter | 4.1 | **51.7** | **3.7** | 122.6 | +| closure over a `Vec` parameter | 4.1 | **65.6** | **3.7** | 167.3 | + +Only the first row worked, and only because a `Vec` local is one the frame owns the value of. A +captured `number` leaks its cell because a `number` owns no heap memory and so the local was +never listed; a captured parameter leaks its cell because a parameter is the caller's and is +never listed either. The cell is the same 24-byte block in all four rows. + +So the listing and the ownership question are separated. `trackPossibleCell` lists every local +that has storage, owned or not, and `mlirGenFunctionBody` lists every parameter; scope exit then +asks each slot what it turned out to be — a cell gets `ts.ReleaseCell`, a slot the frame owns the +value of gets `ts.ReleaseSlot`, and a slot that is neither has nothing emitted for it and is on +the list only because it might have become a cell and did not. + +That it can only be asked at scope exit is the reason for the two-step. Whether a variable is +captured is not known at its declaration: the closure that captures it is written afterwards, and +marks the storage when it is generated. Scope exit is generated after both. + +A parameter's list is the function's rather than the body block's, which is the scope a parameter +actually has, and wiring it in exposed something that had always been wrong and was merely +unreachable. A scope exit walks outwards through `parentBlockContext`, and a function's context +inherits that pointer from whatever context the function was generated under — for a nested +function, the enclosing function's blocks. The walk used to stop at the first context without a +list, and a function's context never had one; give it one and a `return` inside a lambda starts +releasing the *enclosing* frame's locals, which crashes the compiler. `parentBlockContext` is now +cleared at the function boundary, where the walk always should have ended. + +**The other half is what the cell holds.** A cell's release releases its contents, which is right +when the frame put an owned value there and an over-release when it did not — and a captured +parameter's cell starts out holding the caller's argument. Rather than teach the release to skip +those, the cell takes a reference to the value it is initialised with unless the frame already +took one (`OWNED_LOCAL_ATTR_NAME` says so). Then **a cell owns what it holds**, unconditionally, +and every reader can rely on it: + +- the cell's release gives that reference back; +- `isCapturedCellSlot` (§9.34) already made a store *through the box* hand the count over; +- `isOwningSlot` now recognises the cell from the declaring frame's side too, so `v = new Vec(..)` + written where the parameter is in scope takes the new value and gives up the old. + +Without the last of those, the store takes nothing and §9.30 frees the value as a discarded +temporary — the same failure §9.34 found on the box side, arriving from the other direction. + +Three new cases in `00owned_closures.ts`, each with **teeth** confirmed by disabling one fix and +rebuilding: + +| case | fails without | +| --- | --- | +| `capturedParameterEscapes` | the cell's retain of the argument | +| `mutateCapturedParameter` | the cell's retain of the argument | +| `capturedParameterReassignedInFrame` | the cell in `isOwningSlot` | + +Each fails only for its own fix and passes for the other, and the **release-before-retain swap** — +emitting the cell's retain ahead of the store rather than after it, so it retains whatever the +block happened to hold — crashes all three. The release side cannot be given teeth by a test, as +always: it leaks, and the measurements above are what stand in for it. + +`capturedParameterEscapes` is two frames deep on purpose. `new Vec(50)` is a discarded temporary +of the *middle* frame, released at the end of that block, so a cell that had not taken a reference +loses the value before the outermost frame ever reads it. Written one frame shallower the release +lands after the read and the case passes with the bug present — the same trap §9.30 set, and the +reason an ownership case has to build in one block and read in another. + +Two exclusions, both about where a release would land rather than about the variable: a +declaration directly inside a try body (its release is repeated in the cleanup region, which +cannot see storage declared in the body — `localTakesOwnership` pairs its answer with hoisting for +exactly that reason, and hoisting every local of every try body is not a trade this makes), and +one in a catch or finally clause, excluded on the same terms §9.24 excludes it. A captured +variable declared in either keeps leaking its cell. + +`raytrace` at `-O3` is unchanged at **2.6 MB** against `gc`'s 4.2 and `none`'s 104.8 — §9.34 had +already taken its cells, which are all captured locals holding objects. + +Full release suite green: 925/925. Ownership verifier unchanged at its two standing findings. + +A corpus sweep under `-mm=rc` — every test file JIT'd, exit codes compared against the same sweep +on §9.34's commit — turned up one difference, `22lambdas.ts`, and it is **not this slice's**: it +fails about one run in four under `rc` on both commits (3 bad in 15 before, 4 in 15 after) and +never under `gc` or `none`. Filed as 5s. The sweep is worth keeping as a tool, and worth fixing +that flake first, since a flaky instrument is a poor way to measure the next slice. + +### 9.36 Step 5s: boxing into `any`, and the flake that led there + +§9.35 left a corpus sweep behind — every test file JIT'd under `-mm=rc`, exit codes diffed +against the same sweep on the previous commit — and one file, `22lambdas.ts`, failed about one +run in four under `rc` and never under `gc` or `none`. Nondeterministic and model-specific is +what a use-after-free looks like from outside: some run frees something still referenced, and +whether the reused block is fatal to read depends on what was allocated over it. + +Halving the file three times reached this, which fails **5 runs in 5**: + +```ts +function build() { + let fns: any[] = []; + for (let k = 0; k < 3; k++) { + const kk = k; + fns.push(qux2); + function qux2() { glb1 += kk; } + } +} +``` + +with `STATUS_HEAP_CORRUPTION`. Three of the four ingredients turned out to be required and one +did not. Pushing the *same* closure with no captures is clean; pushing strings is clean; a typed +`(() => void)[]` instead of `any[]` is clean; the loop is not needed at all. So: a closure, with +a capture box, boxed into `any`. + +The IR says the rest: + +```mlir +%11 = "ts.CreateBoundFunction"(%10, %9) {__owned_result, __owns_capture} +"ts.Retain"(%11) // the closure takes its capture box +%12 = "ts.Cast"(%11) : (!ts.bound_func<..>) -> !ts.any +"ts.Retain"(%12) // the array takes the box +"ts.ArrayPush"(%2, %12) +"ts.Release"(%11) // §9.30: nothing received the closure +``` + +Boxing allocates a block and copies the value into it, and the box **owns** what it holds: when +it dies it releases the payload through the type tag beside it (`buildBody`, AnyType). Nothing +took that reference. The closure arrived carrying one — `__owned_result`, the reference +`resolveFunctionWithCapture` takes on the capture box — and, no receiver having claimed it, +§9.30 gave it back at the end of the block. The capture box was freed while the `any` in the +array still pointed at it, and the array's own release then released it a second time. + +The fix is one line in `cast()`: where the destination is `any`, `mlirGenRetainCaptured` — the +same consume-or-retain every other owning receiver uses. Consume where the payload already +carries a reference, retain where it does not. + +The two halves of that matter separately, and the cases prove it separately. A payload that +carries a reference and is *not* consumed is the crash above. A payload that carries none and is +not retained is a plain leak — `out.push(makeName())` where the frame also still holds the +string — and `stringBoxedAndStillHeld` is the case for it. Four new cases in a new file, +`00owned_any_boxing.ts`, and **all four abort with the fix disabled**. + +They run as `--emit=exe`, or through `test-runner`, but not as a bare `tslang --emit=jit`. That +is worth writing down because it cost an hour: `___unbox` throws on a type mismatch, which pulls +in the CRT's `type_info` vftable, and only `--shared-libs=TypeScriptRuntime.dll` makes that +symbol resolvable — which `test-runner` passes and a hand-written sweep does not. The sweep +script was wrong in exactly that way, and fixing it dropped its failure count from 161 to 118 of +473 files. **A sweep that has not loaded the runtime is measuring the harness.** + +**A second bug, and it is §9.35's.** With the sweep fixed, the before/after diff showed +`15references.ts` newly crashing, and it has no `any` in it at all: + +```ts +let x: string; +const f = () => { if (1 > 1) x = "foo"; }; +f(); +``` + +A captured declaration with **no initializer**. Its cell is a *heap* block, so what it holds +before the first store is whatever the allocator last left there — and §9.35 lists every local, +initialised or not, so scope exit releases the cell and the cell releases its contents. An owned +local can never be in this position: it is only owned when it has an initializer, which is why +§9.24's null-store was written for the hoisted-out-of-a-`TryOp` case alone. Cells are now zeroed +at birth on the same terms. + +Its two cases needed care to make deterministic. `churn()` before the declaration caught it about +one run in ten, because the block has to be the right size *and* still hold something fatal to +release. Running a *cell of exactly this shape* first — `writtenCell`, which allocates one, frees +the string in it and then frees the cell — hands the next declaration a block holding an +already-freed string pointer, and that is 10 runs in 10. + +929/929 with the four new tests registered. Ownership verifier unchanged at its two standing +findings. The final sweep diff against §9.35's commit is two lines: `22lambdas.ts` 3 bad runs → +0, and the new file. + +**What the measurements then said, which is not about `any`.** Boxing in a loop still leaks under +`rc` — 39.9 MB against `gc`'s 4.1 — and taking it apart puts the leak somewhere else entirely: + +| 500k iterations, `-O0` | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| `push(k)` into `number[]` | 2.6 | 2.6 | 2.6 | +| `push(k)` into `any[]` | 2.6 | 3.7 | 32.3 | +| `let s = "s" + k`, no array at all | 4.1 | **34.5** | 42.8 | +| `push("s" + k)` into `string[]` | 4.1 | 32.3 | 52.2 | + +The array machinery is flat, the `any` box is flat, and a concatenated string held by a local +leaks on its own. Filed as 5t, and it is the one to take next: strings are Tier C, the narrow +first shipping scope this evaluation recommends. + +### 9.37 Step 5t: a string that nothing owns + +The `any`-boxing benchmark from §9.36 still leaked after that fix — 39.9 MB against `gc`'s 4.1 — +and taking it apart moved the leak out of `any` entirely, then out of arrays: + +| 500k iterations, `-O0` | `gc` | `rc` before | `rc` after | `none` | +| --- | --- | --- | --- | --- | +| `push(k)` into `number[]` | 2.6 | 2.6 | 2.6 | 2.6 | +| `push(k)` into `any[]` | 2.6 | 3.7 | 3.7 | 32.3 | +| `let s = "s" + k`, no array, no `any` | 4.1 | **34.5** | **3.7** | 50.6 | +| `push("s" + k)` into `string[]` | 4.1 | 32.3 | **3.7** | 72.6 | +| `push(new Vec(k))` and `push("s" + k)` into `any[]` | 4.1 | 39.9 | **3.7** | 130.1 | + +The third row is the whole story and it has no container in it at all. `("s" + k)` allocates +twice — the conversion prints `k` into a fresh buffer (`ConvertLogic`'s itoa/f64ToString), and +the concatenation builds another (`ts.StringConcat`) — and neither said it was handing anyone a +reference: + +```mlir +%5 = "ts.Cast"(%3) : (!ts.number) -> !ts.string // allocates; unmarked, unreferenced +%6 = "ts.ArithmeticBinary"(%4, %5) ... // allocates; the local retains it +%7 = "ts.Variable"(%6) {__owned} +``` + +`%6` was fine by accident: it is born unowned, the local retains it, the local's scope exit +releases it. `%5` is the leak. Nothing received it — being an operand of a concatenation is not +receiving — so no receiver retained it, and §9.30 only releases what carries `OWNED_RESULT`, so +it did not release it either. Every number ever printed into a string leaked. + +Both now do what `new` and a call do: **a `ts.Retain` making the reference real, and +`OWNED_RESULT` saying a receiver may take it over rather than adding one.** The two halves have +to travel together, and that is the whole design point — the mark alone hands a receiver a +reference nobody made, and the retain alone is a leak with extra steps. It is also what makes +this safe to be generous with: a value wrongly counted as fresh gains a reference and a release +for it, which is balanced. + +Marked: the plain cast to `string` where the source is a number, an integer, an index or a char +(the printing conversions — a boolean, `undefined` and a string literal all hand back a global, +which is immortal), and `+` where the result is a string, which is the one arithmetic operator +that allocates. Class, array and tuple `toString` are ordinary calls and were already handled by +§9.27. + +**The second bug, and it is much older.** Measuring the array rows turned up `00map.ts` failing +under `rc` — 6 runs in 20, and 0 in 20 under `gc` and `none`. It reduces to: + +```ts +arrS.map((e) => e + "_") +``` + +and the default library's `map` is `result.length = this.length; for (..) result[i] = func(..)`. +Growing an array is `MemoryRealloc` and nothing zeroes the tail, so the new slots hold whatever +was last in that memory — and an element store gives up what the slot held first +(`isOwnedElementSlot`), so the release reads it. `SetLengthOfOpLowering` now zeroes the exposed +tail, under `-mm=rc` and only where an element owns something. + +It was tempting to read this as §9.37's own regression, because the sweep diff showed `00map.ts` +newly crashing. Rebuilding §9.36's commit and running it twenty times is what settled it: 6 bad +there too. **One clean sweep run is not evidence about a file that fails a third of the time** — +the same trap §9.36 walked into from the other side, where a single lucky run hid it. + +Four new cases in `00owned_strings.ts`, and the two halves have separate teeth: + +| probe | fails | +| --- | --- | +| mark the fresh string without retaining it | the three string cases, 10 of 10 | +| do not zero a grown array's new slots | `grownArrayHoldsItsStrings`, 12 of 12 | + +Neither probe touches the other's cases. The string cases have no teeth against the *leak* — +nothing can, a leak being invisible — so what they guard is the over-release the fix could +introduce, which is why "mark without retain" is the probe that matters for them. + +Two things were needed to make them read a freed buffer reliably. **Compare the whole string, +not its length**: a freed buffer keeps its length long after its bytes are written over, and the +length-comparing versions passed 8 runs in 8 with the bug present. And, for the array case, +**eight rounds of a same-shape scratch array** rather than a general `churn()`: what makes an +unwritten slot fatal is holding something that looks like a string, and one round got three runs +in four where eight gets all of them. + +933/933. Ownership verifier unchanged at its two standing findings. The sweep diff against +§9.36's commit is one line, the new file. `raytrace` at `-O3` is unchanged at 2.6 MB against +`gc`'s 4.2 and `none`'s 109.0. + +### 9.38 Step 5u: reading the sweep, and the configurations nobody had swept + +The `-mm=rc` corpus sweep had sat at 117 non-zero exits of 475 for three slices running. It was +stable enough to be useful as a regression detector - a diff against the previous commit found +§9.36, and half of §9.37 - and it had never once been read. Reading it needed one question: +**what does `gc` do with the same file and the same command line?** Whatever fails under both is +not the memory model. + +| bare `--emit=jit -mm= --shared-libs=...`, 475 files | non-zero | +| --- | --- | +| `gc` | 101 | +| `rc` | 117 | +| fails under `rc`, passes under `gc` | **16** | +| fails under `gc`, passes under `rc` | 0 | + +So 101 of the 117 have nothing to do with reference counting, and every one of them is the +sweep's own doing: + +- **79 are one half of a two-file test.** Every `export_*`, `import_*`, `decl_*` and `emit_*` + file, plus `component`/`service` and `shared`/`use_shared`. The exporting half has no `main` + (`Symbols not found: [ main ]`); the importing half needs its partner built into a DLL first + (`Symbols not found: [ M.Animal..new, ... ]`). JITing either one standalone cannot work by + construction. +- **20 are the default library colliding with the test.** `redefinition of symbol named + 'Math.PI'`, `'sqrt'`, `'Number..instanceOf'`, `'Error..instanceOf'` - tests that declare their + own and are meant to be compiled without the default library - plus a few type errors + (`conditionalTypes1`, `path`, `raytrace-0`) that are compiler limitations, identical under both + models. +- **2 fail identically under both models**: `arrayLiterals2ES5` faults under `gc` too, and + `import_object_literal_untyped_multi_method` fails an MLIR verifier in both. + +That second group is the interesting one, because it says the sweep had never been running these +tests the way the suite runs them. `test-runner` compiles with **`--opt --opt_level=3 +--no-default-lib`**; the sweep compiled bare - `-O0`, with the default library. Those are not the +same program, and neither configuration dominates the other: + +| | `gc` | `rc` | rc-only | +| --- | --- | --- | --- | +| bare, `-O0`, with the default library | 101 | 117 | 16 | +| `--opt --opt_level=3 --no-default-lib` - what the suite runs | 87 | 109 | **22** | +| union of the two | | | **29** | + +Only 9 files are in both lists. `gc` stays clean in the other direction in both configurations: +nothing passes under `rc` that fails under `gc`. + +**Exactly one of the 29 is a file ctest ever runs under `rc`.** 39 of the 475 test files are +registered as `test-jit-rc-*`; the other 436 have never been compiled under `-mm=rc` by anything +but this sweep, and everything the ownership work has been measured against lives inside that +39. + +The ownership verifier reports nothing for any of the 29, and that is structural rather than a +gap. It checks that an acquired slot is given back on every path out of a function, which is the +*leak* direction. All 29 are faults - `0xC0000005`, `0xC0000374`, a breakpoint - which is the +over-release direction, and it cannot see that at all. + +#### The configuration columns are not four bugs + +Running all 29 under `rc` in all four combinations of `{-O0, -O3} x {default library, none}`: + +| how many configurations fault | files | +| --- | --- | +| all four | 11 | +| some but not all | 18 | + +The 18 are the point. A use-after-free that passes is a use-after-free that got away with it, and +what the columns vary is the heap layout, not the program's ownership. `00array3` faults with the +default library at both optimisation levels and passes without it; `01map` is the exact opposite +of `00spread`; `00generator4` faults at `-O3` only, and on one run it faulted, on the next it +hung, and on a third it came back with a failed `assert` - the same bug wearing three symptoms. +So the honest count is not 16, or 22, or 29: it is that **29 files break the memory model and 18 +of them only break it sometimes**, which is what this class of bug looks like from outside. + +#### Three of them reduce + +The 29 are not 29 bugs. Three reductions, each `rc`-only, each 10 runs in 10: + +**A generator that iterates another generator**, at `-O3`, with or without the default library: + +```ts +function* inner() { yield 1; yield 2; } +function* g() { for (const o of inner()) yield o; } +function main() { for (const x of g()) print(x); print("done."); } +``` + +Faults before printing anything. `yield* inner()` and an inline `(function*(){...})()` fault the +same way; one generator on its own is fine, and `-O0` is fine. + +**A `for...of` element that is itself a reference**, with the default library, at either +optimisation level: + +```ts +const a = [[1], [2]]; +for (const v of a) print(v.length); +``` + +Also faults before printing. A single-level `for...of` is fine and `a.length` on the same array +is fine, so it is the loop variable taking an element that owns something. `--no-default-lib` +hides it because it routes `for...of` over an array through the built-in intrinsic loop instead +of the library's iterator protocol. + +**`await`**, in all four configurations, and the only one of the three that gets the answer +right first: + +```ts +async function f() { return 1; } +function main() { const r = await f(); print(r); print("done."); } +``` + +Prints `1` and `done.` and then dies of heap corruption on the way out - a double free at +teardown. Calling `f()` without awaiting it is fine, so the `await` is what frees something +twice. + +Between them these cover the generator/iterator cluster (`00generator4/5/6`, +`00funcs_expression_iterator`, `01iterator`, `00iterator_bug`), the array-iteration cluster +(`00array3`, `00for_of`, `19forof`, `01map`, `00tuple_with_array`, `arrayLiterals`) and the +coroutine pair (`00async_await`, `00for_await`). The rest - object destructuring, union +narrowing, class statics, and the two whole programs `nbody` and `raytrace` - has not been +reduced. + +#### Two things about measuring this + +**Sweep the configuration the suite uses, not a configuration.** A missing `--no-default-lib` +manufactured 20 failures; a missing `--opt --opt_level=3` hid 13 real ones. The recipe worth +keeping is: sweep both configurations under both models, diff, and read only the rc-only column. +That is what turns the sweep into a to-do list. This is the second time the sweep's own command +line was the bug - §9.36 lost 43 files to a missing `--shared-libs`. + +**Read exit codes in PowerShell, not in bash.** A Windows fault code does not survive bash's +8-bit exit status. The `await` reduction reported `0/10` failures through a bash `||` and +`10/10` - every one of them `-1073740940` - through `Start-Process`'s `ExitCode`. The one that +reads clean is the wrong one. + +Nothing was changed in this slice. 933/933, verifier unchanged at its two standing findings. + +### 9.39 Step 5v: a generator's state object, and the field that was never written + +§9.38's first reduction was a generator that iterates another generator, faulting at `-O3` and +printing nothing. Narrowing it took the inner generator out of it entirely: + +```ts +function* g() { const a = [1]; yield a[0]; } +function main() { for (const x of g()) print(x); print("done."); } +``` + +`rc` only, `-O2` and above, no output at all. Take the local away - `function* g() { yield +mk()[0]; }` - and it passes. So it is not the nesting and not the iterator protocol: it is **a +generator with one local that holds a reference**. + +"No output at all" is a strong hint, and the LLVM dump says exactly what happened: + +```llvm +define void @main() local_unnamed_addr { + unreachable +} +``` + +The whole of `main` is gone. LLVM proved the program had undefined behaviour on every path and +folded it away, which is why `-O0` and `-O1` "work", why `-O2` does not, and why nothing printed. + +#### Where the undefined behaviour came from + +A generator lowers to a state object holding one field per local, built and returned by the ramp +function before the body has ever run: + +```mlir +%2 = ts.Constant { value = [0 : si32, unit, @next] } + : const_tuple<{".step",si32},{"a",!ts.array},{"next",...}> +%4 = ts.Load(%3) +%5 = ts.New() +"ts.Retain"(%4) // walks the tuple's owning fields - including "a" +"ts.Store"(%4, %5) +``` + +`"a"` is the generator's local, and its initial value is `unit`: nothing writes the field until +the body runs. `getTupleFromArrayAttr` lowered `unit` to `mlir_ts::UndefOp`, so `"a"` was +`undef`, and the retain walks into it: + +```llvm +%2 = getelementptr i8, ptr undef, i64 -8 ; step back to the block header +%3 = load i64, ptr %2 ; read the refcount +``` + +Loading through `undef` is undefined behaviour, so the optimizer may conclude that anything +reaching it is unreachable - and it does, out through `g` and into `main`. The release side is +worse in principle even though nothing gets that far: tearing the object down would decrement a +refcount at a garbage address and free whatever block it landed in. + +**An unspecified field of an owning type now lowers to zero rather than undef**, under `rc`, +where `MLIRTypeHelper::ownsHeapMemory` says the field owns something: + +```llvm +store { i32, { ptr, i64 }, ptr } { i32 0, { ptr, i64 } zeroinitializer, ptr @...next }, ptr %1 +``` + +Null is the value the rest of the model already handles - `__tslang_inc_ref` and +`__tslang_dec_ref` both test for it - so the retain at construction is a no-op, the release at +teardown is a no-op, and the first real write to the field takes ownership the way any field +store does. `gc` and `none` are untouched. + +#### What it closed + +| sweep | before | after | newly fixed | newly broken | +| --- | --- | --- | --- | --- | +| bare, `-O0`, default library | 117 | 115 | `00for_of`, `44toplevelcode` | none | +| `--opt --opt_level=3 --no-default-lib` | 109 | 102 | `00extension_cond_access`, `00funcs_expression_iterator`, `00generator4`, `00generator5`, `00iterator_bug`, `00map`, `01iterator` | none | + +That is 5v's predicted cluster except `00generator6`, plus three files nobody had attributed to +it - `00extension_cond_access`, `00map` and `44toplevelcode`, none of which contains a generator. +Any const tuple with an unwritten owning field was hitting this. + +§9.38 named `-O3` **with** the default library as a configuration nothing had ever swept, so this +slice swept it: `rc` 117 non-zero against `gc`'s 101, 16 rc-only. It surfaces one file the other +two configurations never showed, `00typed_array`, and rebuilding the previous state confirms it +faulted there before this change too (6 runs in 6, `gc` clean) - newly *found*, not newly broken. +Across all three configurations the rc-only set is **29 -> 22**. + +One file has to be reported rather than counted. `01map` faulted in two of the four cells before +and faults in two after, but not the same two: `-O0 --no-default-lib` went 6 runs in 6 to 0, and +`-O3` with the default library went 0 to 6, both measured with repetitions on both sides. +Turning `undef` into null cannot manufacture an over-release - it removes an undefined read and +makes two existing null checks fire - but it does change what the optimizer emits and so where +everything lands, and `01map` still carries 5w's bug. This is the sensitivity §9.38 measured: +18 of the 29 fault in only some configurations, and the configuration is the heap layout. + +#### Teeth + +`00owned_generators.ts`, five cases, each keeping a reference-typed local alive across a +suspension: an array, a string, an iterator over another generator, a `yield*` delegation, and a +local mutated between two resumptions. + +| probe | fails | +| --- | --- | +| lower an unspecified owning field to `undef` again | all five, `rc` at `-O3`, 6 runs in 6 | +| the same probe under `gc`, under `none`, or at `-O0` | nothing | + +The `-O0` row is the point rather than a gap: the bug is undefined behaviour, so what it needs to +become a fault is an optimizer willing to act on it. The tier that has teeth here is the one +ctest actually runs. + +#### What the fix made measurable: a generator with a parameter leaks + +These programs could not be measured before, because they crashed. 500k iterations, `-O3`: + +| | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| generator with an array local, no parameter | 2.6 | **2.6** | 34.4 | +| generator with a string local, no parameter | 4.1 | **3.7** | 55.8 | +| generator with an array local **and a parameter** | 4.1 | **22.7** | 40.2 | +| generator with a string local **and a parameter** | 4.1 | **46.3** | 127.0 | +| a generator that yields a freshly built string | 4.1 | **76.8** | 71.2 | + +The parameter is the whole variable - the local's type makes no difference either way. A +parameter makes the coroutine capture, and the capture box becomes a fourth field of the state +object, which the object's release routine does not walk: + +```llvm +define internal void @tsrel_5704117(ptr %0) { + ... + %7 = getelementptr { i32, ptr, ptr, ptr }, ptr %2, i32 0, i32 1 + call void @tsrel_5061303(ptr %7) ; field 1, the string local + call void @__tslang_free_block(ptr %2) +``` + +Field 3 is the box, and nothing gives it back. It is the same predicate on both ends: the box +field is the one field this slice's fix did *not* zero, because `ownsHeapMemory` does not call it +owning - which is exactly why the release routine skips it too. The last row is worse than +`none`, so there is a second leak on the yield path as well. Filed as 5z. + +937/937. Ownership verifier unchanged at its two standing findings. `raytrace` at `-O3` is 2.6 MB +against `gc`'s 4.2 and `none`'s 108.9. + +### 9.40 Step 5w: what a `for...of` hands the loop variable + +```ts +const a = [[1], [2]]; +for (const v of a) print(v.length); +``` + +`rc` only, with the default library, at every optimisation level. It took three separate fixes, +and the first two are the same mistake made twice. + +#### A literal array is a block with no header + +`main` was real code this time, not §9.39's single `unreachable`, and the globals said why: + +```llvm +@s_6682479467004374669 = internal constant [14 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FFdone.\00" +@a_1171826144013 = internal constant <1 x i32> splat (i32 1) +``` + +A string literal carries the eight all-ones bytes that read as `HEAP_BLOCK_IMMORTAL` - §9.5 put +them there so a pointer to a literal and a pointer to a heap string are the same shape. **An +array literal carries nothing.** Bind one to a loop variable and the retain reads the word in +front of a read-only global, and the release writes it. + +`getOrCreateGlobalArray` now emits the same header under `rc`, and the pointer it hands back +points past it - exactly what `getOrCreateGlobalString_` has always done. + +That fixed `-O0` and left `-O3` still faulting, for a reason worth keeping: + +```llvm +@a_3733085596457652 = internal constant { i64, <3 x i32> } { i64 -1, <3 x i32> } + ; ... i64 16), i64 3 } +``` + +Three `i32` want sixteen-byte alignment, so an unpacked struct pads and the payload starts at +offset **16** - the header is two words back, not one, and everything reading `data - 8` reads +padding. `[1]` and `[2, 3]` were fine and `[4, 5, 6]` was not, which is why the nested case +printed `1 2 3` and then died. The block struct is packed now. **A header is only a header if it +is exactly one word in front**, and a payload's own alignment is enough to break that. + +#### The value an iterator hands back when it has none + +Still faulting from `-O1` up, with the last thing printed lost to an unflushed buffer. The +generator's exit block: + +```llvm +%.sroa.0.0 = phi ptr [ undef, %1 ], [ %.unpack47, %14 ], [ undef, %10 ] +``` + +and its caller: + +```llvm +%.not = icmp eq ptr %.fca.0.0.extract, null +br i1 %.not, label %..., label %9 +9: %10 = getelementptr i8, ptr %.fca.0.0.extract, i64 -8 + %11 = load i64, ptr %10 +``` + +**The caller retains the result before it looks at `done`.** On the final call the value is +`undefined` coerced to the element type, which `castToArrayType` materialised as `undef`, and +reading a refcount through `undef` is §9.39's bug wearing different clothes - this time the +`undef` is a runtime phi rather than a constant, which is why `-O0` survived it: the value sat +in an alloca that happened to hold something benign until mem2reg replaced it. + +`undefined` as an array is now the empty array `{ null, 0 }`, and `UndefOpLowering` gives the +same treatment to any `mlir_ts::UndefOp` whose type owns heap memory - which is the path a +generator yielding object literals takes. Both are `rc`-only. + +#### What it closed + +| sweep | before | after | newly fixed | newly broken | +| --- | --- | --- | --- | --- | +| bare, `-O0`, default library | 115 | 106 | 9 | none | +| `--opt --opt_level=3 --no-default-lib` | 102 | 97 | 5 | none | +| `-O3`, default library | 117 | 107 | 10 | none | + +Across the three configurations the rc-only set goes **22 -> 12**. That is all of 5w's predicted +cluster - `00array3`, `00for_of`, `19forof`, `01map`, `00tuple_with_array`, `arrayLiterals` - +plus `00interface_object4`, `39objectdestructuring`, `typeGuardOfFormThisMember`, +`00object_global` and `00mixed_type_ops`, none of which was attributed to it. `01map`, the file +§9.39 had to report rather than count, is clean in all four cells now. + +#### Teeth + +`00owned_iteration.ts`, seven cases: literal rows iterated and read again afterwards, ragged rows +(the three-wide one is the alignment case), an empty array that iterates no times, records from +an array, records from a generator, and strings from a generator. Each change reverted on its +own, three runs each: + +| probe | fails | +| --- | --- | +| no header on a constant array | `rc` with the default library, both levels, 3/3 | +| the header struct unpacked | `rc` with the default library, both levels, 3/3 | +| `undefined` as an array back to `undef` | `rc`, `-O3`, with the default library, 3/3 | +| `UndefOpLowering` back to `undef` | `rc`, `-O3`, **with and without** the library, 3/3 | + +Nothing fails under `gc` under any probe, which is the expected shape: all three are retains only +`rc` emits. The last row is the one that matters for coverage - the other three need the default +library, and ctest compiles with `--no-default-lib`, so without the generator-of-records case +this file would have had no teeth at all in the tier it is registered in. That is 5y in +miniature. + +#### One residual + +With the default library, iterating a literal array 900k times cost `rc` 15.6 MB against `gc`'s +4.1 and `none`'s 155.9 - so `rc` reclaimed about nine tenths of it and held the rest. The gap grew +sublinearly (nothing at 100k, 7.4 MB at 300k, 11.5 MB at 900k), which looked more like the +allocator's high-water mark than an unbounded leak. Filed as 5aa - and **it does not reproduce**, +see §9.58. + +941/941. Ownership verifier unchanged at its two standing findings. `raytrace` at `-O3` is +2.6 MB against `gc`'s 4.2 and `none`'s 99.3. + +### 9.41 Step 5x: two allocators for one coroutine frame + +```ts +async function f() { return 1; } +function main() { const r = await f(); print(r); print("done."); } +``` + +prints `1` and `done.` and then dies of heap corruption, under `rc` and under `none`, at every +optimisation level, with and without the default library. `gc` is clean. That `none` fails is the +whole diagnosis: `none` frees nothing at all, so nothing here was ever a double free, and nothing +here was ever reference counting's. + +The frame allocation says the rest: + +```llvm +%6 = call ptr @aligned_alloc(i64 8, i64 %5) ; the coroutine frame +... +call void @free(ptr %0) ; ... and its destroy path +``` + +`aligned_alloc` paired with `free` is the C11 pairing and is right everywhere `aligned_alloc` +exists. MSVC has no `aligned_alloc`, so the runtime supplied one - built on `_aligned_malloc`, +whose memory may only go back through `_aligned_free`. Every awaited call allocated a frame on +one heap and released it on another. Under `gc` it never showed, because `GCPass` rewrites the +whole pair to `GC_memalign`/`GC_free` and the two agree again. + +Windows' own `malloc` is aligned to 16 bytes, which is more than the frame's 8, so the shim now +serves the request from the ordinary heap and the pairing is honest. Over-alignment is the one +thing it cannot do and stay `free`-compatible, so it says so on stderr rather than handing back +something quietly wrong. `AlignedFree` becomes plain `free` to match. + +#### The same symbol, one layer out + +With that fixed in the JIT, the ahead-of-time path turned out never to have worked at all: + +``` +error LNK2019: unresolved external symbol aligned_alloc referenced in function main +``` + +`-mm=rc` and `-mm=none` could not link an executable that awaited anything, and `-mm=gc` could +only because the call had been renamed away. Nothing had noticed because ctest's AOT tier runs +under the default model only - the same shape of gap as 5y. `TypeScriptAsyncRuntime`, the static +library the linker is given, now defines `aligned_alloc` itself, with the same body. + +#### What it closed + +| sweep | before | after | newly fixed | +| --- | --- | --- | --- | +| bare, `-O0`, default library | 106 | 104 | `00async_await`, `00for_await` | +| `--opt --opt_level=3 --no-default-lib` | 97 | 96 | `00async_await`, `00for_await` | +| `-O3`, default library | 107 | 107 | `00async_await`, `00for_await` | + +Two files in each configuration appeared to break and neither did: `44toplevelcode` timed out +because four sweeps were running at once and passes 6 runs in 6 on its own, and +`00mixed_type_ops` at `-O3`-with-the-library fails 6 in 6 both before and after - which also +corrects §9.40, where a single lucky run had it in that slice's fixed column. The rc-only set +across the three configurations goes **12 -> 11**, and 10 of those are real: the eleventh is +`44toplevelcode`'s timeout. + +And **`none` now has no failure that `gc` does not share** - zero files, under the suite's own +flags. That is the first time any model other than `gc` has been clean against it. + +#### Teeth + +`00owned_async.ts`, six cases: an await, an async arrow, an async function with a default +parameter, three awaits in sequence, an async function awaiting another, and 64 awaits in a loop. +Reverting the runtime: + +| probe | fails | +| --- | --- | +| `_aligned_malloc` back in the JIT shim | `rc` 3/3 and `none` 3/3, both levels; `gc` clean | +| `aligned_alloc` out of the static library | `rc` and `none` do not link; `gc` links | + +Every awaited function in the file is parameterless and returns an `i32`, because at the time +anything else failed with `error: failed to legalize operation 'async.runtime.load'`, in any +model. That was filed as 5ab and read as being about arguments; it was not - see §9.56, where the +real condition turned out to be the **result type** and both halves are now fixed. + +#### What it costs + +50k awaits at `-O3`: `gc` 7.9 MB, `rc` 7.6, `none` 7.7. The frame is freed by the coroutine's own +destroy path in every model, so awaiting costs the same in all three and there is nothing here +for reference counting to own. + +Two things came out of measuring it, neither this slice's: + +- **`gc` faults on a long chain of coroutine frames**, 2 runs in 4 at 50k awaits and worse at + 200k, in both the old and the new link configuration, so it predates this change and is not + the allocator pairing. Filed as 5ac. +- **An `-mm=rc` executable that prints a number exits 1**, deterministically, with correct + output. It needs no async at all - `function main() { print(1); }` does it - and only the + ahead-of-time path, never the JIT. Filed as 5ad. + +945/945. Ownership verifier unchanged at its two standing findings - necessarily, since nothing +in the compiler changed, only the runtime libraries. `raytrace` at `-O3` is 2.6 MB against `gc`'s +4.2. + +### 9.42 Step 5y: the tier that was 42 files of 478 + +`-mm=rc` ran 42 files of the corpus and `-mm=none` ran 24, and neither ran one of them ahead of +time. The ahead-of-time tier is a real one - `test-compile-*` compiles, links with `lld` and runs +the executable - but it has only ever run the default model, so a fault had to be wrong in the +default model *and* in the JIT before anything here would say so. §9.41's was neither. Nor was +§9.39's or §9.40's: all three were found by a sweep script that lived in a scratch directory and +ran when somebody remembered to run it. + +Every single-file test the default model runs is now run again, in the same tier, under `rc` and +under `none`, and so is every shared-component pair. The corpus is not a second list to keep in +step with the first: the existing entries *are* the list, and the new tiers are a `foreach` over +it, so a file added for the default model arrives in all three at once. + +| | before | after | +| --- | --- | --- | +| `rc`, JIT | 42 files | 384, ten of them disabled | +| `rc`, ahead of time | none | 385, ten of them disabled | +| `none`, JIT | 24 files | 384 | +| `none`, ahead of time | none | 385 | +| shared-component tests, each of `rc` and `none` | none | 84 | +| tests in the suite | 945 | 2,585 | +| `ctest -j 12` | 18.8 s | about 50 s | + +#### What the first run said + +Ten files, all under `rc`, all in both tiers, and **not one of them under `none`** - which is the +shape of a reference-counting fault rather than a latent one. Nine corrupt the heap +(0xC0000374); `25lamdacapture.ts` just gets the wrong answer. Each was run six or eight times per +tier on its own before being listed: + +| file | JIT `rc` | AOT `rc` | what it is | +| --- | --- | --- | --- | +| `00class_static.ts` | 6/6 | 6/6 | private static fields, and a `delete` | +| `00generator6.ts` | 6/6 | 6/6 | `yield*` of a `number \| string` | +| `00mixed_type_ops.ts` | 6/6 | 6/6 | binary operators across static types | +| `00safe_cast_field_access.ts` | 6/6 | 6/6 | a `number \| null` field, narrowed | +| `00spread.ts` | 6/6 | 6/6 | an array spread into parameters | +| `01class_new.ts` | 6/6 | 6/6 | an interface with a construct signature | +| `25lamdacapture.ts` | 6/6 | 6/6 | a lambda inside a lambda - **wrong answer, no crash** | +| `44toplevelcode.ts` | 0/8 | 1/8 | rare, and the correction to §9.41 below | +| `nbody.ts` | 6/6 | 6/6 | the benchmark | +| `raytrace.ts` | 3/6 | 6/6 | the benchmark | + +Filed together as 5ae. Fixing them is not this slice - the slice is that they are in the suite +now, instead of in a script nobody runs. + +They are registered and **disabled**, rather than left out or marked `WILL_FAIL`. Left out, the +names would not exist and nothing in the build would say what is broken. `WILL_FAIL` was the +first attempt and does not hold: a corrupted heap does not always land, and the last two rows of +that table are not the only ones that wander. `00mixed_type_ops.ts` fails six runs in six on its +own and came up clean once in three runs of the whole suite, where twelve tests at a time give +the allocator a different history - and under `WILL_FAIL` a run that passes is a red suite. +Disabled, the list stays in the build where it can be read, `ctest` counts them out loud, and +the suite stays a suite. + +#### The correction to §9.41 + +§9.41 put `44toplevelcode`'s sweep timeout down to four sweeps running at once. It was not +contention. The file is rc-only broken about one run in eight, and when it broke in the JIT it +did not fail - it stopped on the message box described below, which the sweep could only see as +a test that never ended. The rep check that cleared it ran it on its own, where it passes eight +times in eight. + +#### Two faults in the harness, on the way in + +**A failing `assert` in a JIT run was a hang, not a failure.** Under `--emit=jit` the `assert` in +compiled code calls `_assert`, and the process resolver binds that to `ucrtbase.dll` - a CRT +instance whose report mode nothing here sets, and whose answer to a failed assertion is a modal +message box. The harness then waits on a window nobody is there to close, forever. It had never +come up, because until now every JIT test in the suite passed; the first thing the corpus did was +register several hundred that do not. `jit.cpp` already binds `puts`, `malloc` and the C++ +personality away from `ucrtbase` for the same class of reason, and `_assert` now joins them, +pointed at a shim that writes the message to stderr and exits: + +``` +assertion failed: deliberately false +``` + +The ahead-of-time path was never affected - the generated executable's own CRT sees a console +application and writes to stderr already. `tslang.exe` now also asks Windows not to raise the +error-reporting dialog on a fault, which is worth about twenty seconds a run: nine of the ten +crash, three times over with the harness's retries, and every one of those was sitting in the +reporting UI. + +**The shared-component runner dropped a space.** `--gctors-as-method` was appended to the +compiler options with no separator, so the first shared test ever to carry another flag produced +`--mm=rc--gctors-as-method` and 26 pairs failed to build. Latent since the flag was added, +because nothing had ever passed a second one. + +#### One more, not listed + +`13actions.ts` - closures capturing locals, so the same neighbourhood as `25lamdacapture.ts` - +failed once under `rc` ahead of time, in ten runs of the whole suite. On its own it passes +sixteen runs in sixteen, in both tiers, under all three models, and the `rc` and `none` tiers on +their own are clean ten runs in ten: whatever it is needs the whole suite's load to land. One +sighting is not enough to disable a file on, so it stays registered and is written down here +instead. If it comes back it joins 5ae. + +2,565/2,565, with the ten disabled, over six consecutive runs. The ownership verifier is +unchanged at its two standing findings - necessarily, since nothing in the compiler changed this +time, only the harness and the driver. + +### 9.43 Step 5ae, first: a closure inside a closure + +```ts +let a = 7; +const g = () => { + const h = () => { glb = glb + a; }; + h(); +}; +g(); +``` + +One level of that is fine. Two corrupts the heap, at `-O3`, under `rc` and nothing else. The +generated dialect says why. In the frame that declares `a`: + +```mlir +"ts.RetainCell"(%6) : (!ts.ref) -> () +%9 = "ts.Capture"(%6) : (!ts.ref) -> !ts.ref}>> +%10 = "ts.CreateBoundFunction"(%9, %7) {__owned_result, __owns_capture} +``` + +and inside `g`, capturing the same cell to build `h`'s box: + +```mlir +%1 = "ts.Load"(%0) // `a`, reached through g's own capture box +%4 = "ts.Capture"(%1) : (!ts.ref) -> !ts.ref}>> +%5 = "ts.CreateBoundFunction"(%4, %2) {__owned_result, __owns_capture} +``` + +There is no retain. `mlirGenResolveCapturedVars` emitted one for a `ts.Variable`, a `ts.Param` +and a `ts.ParamOptional` - the three shapes it also has to *mark* as captured, since being captured is +what turns a variable's storage into a cell. An inherited cell is none of those: the frame that +declared it marked it already, and here it arrives as a load of a capture-box field. The +fall-through branch's comment read "nothing was marked" and drew the wrong conclusion from a true +observation. The box being built owns this cell exactly as the first box does, and releases it +when the bound function goes, so it has to take a reference to it first. `isCapturedCellSlot` - +the predicate §9.30 already needed to recognise assignment *through* such a reference - is what +names the shape. + +#### What it closed + +| file | JIT `rc` | AOT `rc` | +| --- | --- | --- | +| `25lamdacapture.ts` | 6/6 -> **0/10** | 6/6 -> **0/10** | +| `raytrace.ts` | 3/6 -> **0/10** | 6/6 -> **0/10** | + +`13actions.ts` and `44toplevelcode.ts`, the two that fail about one run in ten, are unchanged at +one in ten, and the other seven 5ae files are unchanged outright. Two of ten, then - but one of +the two is the benchmark this document has been quoting since §9.31, and that turns out to matter +more than the count. + +#### What `raytrace` actually costs + +| | `gc` | `rc` | `none` | +| --- | --- | --- | --- | +| `raytrace.ts`, `-O3`, three runs each | 4.6 / 4.2 / 4.6 | **67.8 / 61.1 / 60.5** | 95.4 / 99.2 / 99.5 | + +The `rc` column has never been measured before. Every stored `rc` `raytrace` binary from §9.31, +§9.32, §9.33, §9.39 and §9.41 is still on disk, and every one of them exits `0xC0000374` with +**zero lines of output** and a peak of 2.6 MB. That is the number this document has carried as +"below `gc`'s own 4.2" through five slices. It is how far a crashing program got. + +`measure5q.ps1` printed the peak working set and not the exit code. §9.38 wrote down that a +Windows fault code does not survive bash's eight-bit exit status, so read exit codes in +PowerShell; the same lesson had to be learned one level up, where the script does read them and +simply never says so. **A measurement script prints the exit code, or the measurement is of +nothing in particular.** + +What the real numbers say is less flattering and more useful: on the whole program `rc` reclaims +about a third of what `none` leaks, and sits at fourteen times `gc`. The per-shape numbers in +§9.29-§9.37 stand - those were measured on programs that completed - but the whole-program claim +does not, and 5af is now what is left of it. + +#### Teeth + +`00owned_nested_captures.ts`, six cases; with the retain removed, every one of them fails three +runs in three. Two had to be rewritten to get there. A nested closure that is called on the spot +inside a loop is folded away at `-O3` - the inner box never exists, so there is nothing to +mis-release - and the case sat there passing either way. Handing the inner closure back instead +makes the box real and the case bites. The lesson is the older one in a new place: **a case that +passes with the fix reverted is not a case**, and at `-O3` the reason is often that the optimiser +deleted the thing under test. + +The file is registered the way the corpus now allows: two entries for the default model, one line +in `TSLANG_CORPUS`, and the loop supplies the other four. + +2,573/2,573 over three consecutive runs. The ownership verifier is unchanged at its two standing +findings - it never saw this one and could not: an over-release is invisible to a pass that looks +for references acquired and not given back. + +### 9.44 Step 5ae, second: a union that holds nothing yet + +```ts +class A { data: number | null = 10; } +function main() { const a = new A(); print("made"); } +``` + +That is the whole reduction. It faults under `rc` at every optimisation level, and this time with +`0xC0000005` rather than a corrupted heap - an access violation is a different family of mistake, +and says a pointer was followed rather than a count mismanaged. + +Not every union field does it: + +| field | `rc` | +| --- | --- | +| `number \| null` | access violation | +| `number \| string` | access violation | +| `number \| undefined` | fine | +| `string \| null` | fine | + +The two that fault are the two that need a runtime tag. The other two are lowered without one - +an optional behind a flag, and a nullable pointer - so nothing reads a descriptor for them. + +A tagged union's release routine reads the routine to call out of the descriptor its tag points +into: + +```llvm +%3 = load ptr, ptr %2 ; the tag +%5 = getelementptr i8, ptr %3, i64 -32 ; the descriptor sits in front of it +%6 = getelementptr {...}, ptr %5, i32 0, i32 2 ; TYPE_DESCR_RELEASE +%7 = load ptr, ptr %6 +``` + +and the constructor assigns the field the way every owning field is assigned - retain the +incoming value, release the outgoing one, store: + +```llvm +call void @tsretv_14998068(<{ ptr, ptr }> %7) ; the new value +call void @tsrel_14998068(ptr %6) ; the OLD one +store <{ ptr, ptr }> %7, ptr %6 +``` + +The old one is the field as `calloc` left it. Its tag is null, `null - 32` is `0xffff...e0`, and +the load of the release slot is the fault. A union that holds nothing is not a corner case: it is +what every union field is between the allocation and the first assignment, and the first +assignment is the thing that reads it. + +The fix is `emitIfNonNull` around both directions, which is what the interface and closure paths +already do. `releaseViaTagBesideThis` even carries the reason in a comment - "getRecordPtrFromTag +walks backwards from the tag to the record, so a null tag would be dereferenced, not skipped, by +the null check inside releaseViaDescriptor". The observation was right and was written down; it +was applied in one of the two places that needed it. + +#### What it closed + +| file | JIT `rc` | AOT `rc` | +| --- | --- | --- | +| `00generator6.ts` | 6/6 -> **0/6** | 6/6 -> **0/6** | +| `00safe_cast_field_access.ts` | 6/6 -> **0/6** | 6/6 -> **0/6** | + +`00mixed_type_ops.ts` was grouped with these two as "unions" and is not this: it has no union +field, and it is unchanged. Four of the original ten are closed now; five files fail every run +and two fail about one in ten. + +#### Teeth + +`00owned_unions.ts`, six cases, every one of which fails three runs in three with the two guards +removed: a first assignment, three assignments in a row, a union field holding a freshly built +string, two hundred instances each written once, a read through a narrowing test, and the +`yield*` of a `number | string` that `00generator6.ts` is made of. + +Two of them had to be written through a method rather than inline, because an un-narrowed +comparison against a `number | null` field is rejected in strict null mode - correctly, and in +every model. + +2,583/2,583 over three consecutive runs. The ownership verifier is unchanged at its two standing +findings. + +### 9.45 Step 5ae, third: `delete` and the release that follows it + +Under reference counting `delete` already drops a reference rather than freeing outright - +`DeleteOpLowering` has done that since step 6. What it did not do is tell anything else to stop. +The dialect for `00class_static.ts`'s static method says it in four lines: + +```mlir +%3 = "ts.CallIndirect"(%2) {__owned_result} // new c1() +"ts.Delete"(%3) // delete c +"ts.Release"(%3) // ... and the end of the block, again +``` + +`%3` is an owned result nobody claimed, and §9.30 releases those where the producing block ends. +`delete` is a claim, so it now says so: `consumeOwnedReference`, the same mark an assignment +makes when a slot takes a reference over. + +There is a second way to release the same reference twice, and `let c = new C(); delete c;` has +it - the variable has real storage, `delete` works from a load of it, and the slot is released +again on the way out: + +```mlir +"ts.Delete"(%18) +"ts.ReleaseSlot"(%7) +``` + +Storing null into the slot makes the second one a no-operation, since every release routine +checks its pointer first. It is a raw store on purpose - the assignment path would retain and +release around it. + +#### What it closed + +`00class_static.ts`, 6/6 to 0/6 in both tiers. Five of the original ten are closed. + +#### Teeth, and four cases that have none + +One case in `00owned_delete.ts` fails three runs in three with the fix reverted: +`deleteFromAStaticMethod`, which is `00class_static.ts`'s shape - a static method that builds an +instance, prints twice, and deletes it. The other four **double-free and nothing notices**. + +That is worth writing down, because the first four cases written were all of that kind, and the +instinct that produced them was wrong in an interesting way. A freed block keeps its contents +until something is allocated over it, so the standing rule for an over-release is "delete, then +allocate hard, then read". For a *double free* that rule is backwards: allocating in between +hands the block to somebody else, and the second free then quietly corrupts a live object rather +than tripping the allocator's own check on a block still sitting in its free list. What made the +static-method case fault is that the two `print` calls each build a string the block releases +after the second free, on a free list that is already inconsistent. + +The four that do not discriminate are kept as shape coverage - a variable with storage, a loop, an +object owning a string, a delete on one of two branches - and the file says so. + +`delete` also means something different in each model, which bounds what a shared test can +assert: under `gc` and `none` the block is freed outright and a second reference to it dangles, +while under `rc` it is one owner going away and the object survives. A case that held two +references failed in all three models for that reason and was removed rather than made +model-specific. + +2,587/2,587 over three consecutive runs. The ownership verifier is unchanged at its two standing +findings. + +### 9.46 Step 5ae, fourth: the method nobody wrote + +`new C(...)` where `C` is not a class but a value of a **constructor interface** - an interface +whose only member is a `new` signature - is `01class_new.ts`, and it is how a program hands the +choice of what to construct to whoever supplied the constructor. The call goes through that +interface's vtable slot, and the slot is filled by a method the compiler synthesises for the +implementing class (`generateSynthMethodToCallNewCtor`): build the instance, run the constructor, +cast the result to the interface the signature returns, hand it back. + +That method has no source, and so it has no `return` statement - it is built op by op, and the +`ReturnValOp` at the end of it was created directly rather than through the path a `return` takes. +Everything a return does for ownership therefore did not happen: + +```mlir +%6 = "ts.CallIndirect"(%5) {__owned_result} // Impl..new + "ts.CallIndirect"(%9, %8, %arg1) // Impl.constructor +%13 = "ts.NewInterface"(%6, %12) // cast to the declared return type + "ts.Release"(%6) // §9.30: an owned result nobody claimed + "ts.ReturnVal"(%13, %2) +``` + +`%6` is an instance nobody took over, so §9.30 gives it back where its block ends - and its block +is the one that returns an interface over it. Every caller of `new C(...)` through a constructor +interface was handed a block that had already been freed. `b6.ts`, the reduction, prints `0` +where it should print `42`: freshly zeroed memory rather than garbage, because the allocator hands +the block straight back. + +The fix is the retain the ReturnStatement path performs (`mlirGenRetainCaptured`, §9.24), applied +to the value after the cast to the declared return type. `%13` is an interface, an interface owns +what its `this` points at (§9.31), so retaining it is what gives the instance the reference the +caller needs; `%6`'s own is then correctly given back. + +#### The half that was wrong, and how that showed + +The first fix was somewhere else, and it looked more fundamental. `castToInterfaceSpecialCases` +builds the class-to-interface `ts.NewInterface` and marks nothing - no `ts.Retain`, no +`__owned_result` - while the object-literal path a hundred lines below it does both, with a +comment explaining why (§9.37: a fresh-value producer emits the retain **and** the marker, never +one alone). An interface that owns its `this` and takes no reference to it reads as the same +omission, so it was fixed the same way, and `01class_new.ts` went on failing. + +The return retain fixed it on its own. Reverting the cast-site retain and keeping only the return +retain leaves all seven cases of the new test passing and `01class_new.ts` clean in both tiers, +and each of the four cases that discriminate still corrupts the heap in three runs out of three +with the return retain removed. **So the cast-site change was dropped**, and not only because it +was redundant: a retain that balances only where §9.30 agrees to release is a leak everywhere +§9.30 declines - a use outside the producing block, a terminator user, a generator - and it would +have been an invisible one, spread across every class-to-interface cast in the program. + +The method that caught it is the one this arc keeps returning to, applied a step finer than usual: +**teeth measured per fix, not per slice.** Two changes were in the tree, the tests passed, and the +tests would have gone on passing with the wrong one alone. + +#### What is left, deliberately + +The caller of `new C(...)` still leaks one instance. `Impl.Ctor..new_ctor#1` now classifies as +returning owned, but the call that reaches it goes through `ts.InterfaceSymbolRef`, and +`calleeNameOf` answers empty for an interface slot on purpose (§9.32): the slot is filled by +whichever class implements the interface, so reading the declaration as the callee would consume +a reference some other implementation never took. Leaking is the side of that line this arc keeps +everything uncertain on, and it is item 5o rather than this one. + +#### What it closed + +`01class_new.ts`, 6/6 to 0/6 in both tiers. Six of the original ten are closed; four remain - +`00mixed_type_ops.ts`, `00spread.ts`, `nbody.ts`, and the one-run-in-ten pair `13actions.ts` and +`44toplevelcode.ts`. + +New test `test/tester/tests/00owned_construct_interface.ts`, seven cases, each building in one +block and reading in another with `churn()` between. The first four - an instance built through a +constructor interface, two from one constructor value, an instance holding a string, and the one a +loop carries out - each corrupt the heap in three runs out of three with the retain removed, at +`-O0` and `-O3` alike. The last three are the plain class-to-interface cast with no constructor +interface in it; they discriminate nothing, because that path was already correct, and the file +says so. + +2,595/2,595 over three consecutive runs, up from 2,587 - the eight are this file in four tiers +plus `01class_new.ts` coming off the disabled list in two. The ownership verifier is unchanged at +its two standing findings. + +### 9.47 `-nogc` is gone + +The alias §9.6 kept for compatibility outlived its usefulness, and it was not inert while it +waited. An LLVM `cl::opt` accepts an explicit value, and an empty one parses as **true** - +so `-nogc= -mm=rc` compiles `none` and says nothing about it. That is not a hypothetical: it is +how the first round of §9.46's reductions came back reporting that all six of the remaining +`rc` faults had already been fixed, on a build where nothing had changed. A flag whose two +spellings disagree silently about which memory model is in force is worse than no alias. + +The definition in `tslang.cpp` and its one read in `opts.cpp` are removed, so +`compileOptions.memoryModel` is now just `memoryModelOpt.getValue()`, and an old `-nogc` +invocation fails loudly with an unknown-argument error rather than quietly choosing a model. +Every caller in the tree spells the model out: the README's WASM example, the Visual Studio +custom tool's property page and switch, the three WASM scripts under `docs/how/wasm`, the +Compiler Explorer wrapper's three call sites, and three launch configurations. The Compiler +Explorer *ids* (`tslang_jit_nogc`) are left as they are - they are names in a local instance's +config, not flags, and renaming them would move permalinks for nothing. + +`docs/jit-gc-static-roots.md` is updated too, in all four places: those mentions are a +reproduction recipe and a diagnostic checklist rather than a record of what the flag once was, +and a recipe that no longer runs is not a record of anything. + +2,595/2,595. + +### 9.48 Step 5ah: the boxing cast that said it allocated nothing + +`00mixed_type_ops.ts` is closed, and **not by what 5ah proposed.** The plan item blamed the +optimiser - the allocations go, the frees stay - and named two ways out, both of them about +denying LLVM something. Neither was needed. The double free is ours, it is in the IR before +LLVM ever sees it, and the fix is four lines. + +#### Where the second box went + +The five-line reduction stands: + +```typescript +function main() { + let a: any = "abc"; + a = true; + a = false; + a = true; +} +``` + +Counting boxes down the pipeline is the whole investigation, and it takes four commands. +`--emit=mlir-llvm` with no `--opt` has four `llvm.call @malloc` in `main`, four `tsret_` and +four `tsrel_` - correct and balanced. The raw LLVM translation has four `malloc`s too. But +`--emit=mlir-affine --opt` has **three** boxing casts where the `ts` dialect had four, and from +there the LLVM module has three `malloc`s at every optimisation level, including `-O0` where +LLVM does essentially nothing. The box was gone before the optimiser was asked. + +What merged them was generic CSE, on our own dialect. `a = true` appears twice with `a = false` +between, the constant is CSE'd into one op first, and the two casts over it are then +structurally identical - so CSE keeps one. The third assignment therefore stores a pointer to +the box the *second* assignment had already released: + +- box A (`"abc"`) is retained by the slot; +- box B (`true`) is retained, A is released and freed, B is stored; +- box C (`false`) is retained, B is released and **freed**, C is stored; +- the fourth assignment is box B again - retained through freed memory, C released and freed, + and the freed block stored back into the slot; +- the end of the block releases it, and B is freed a second time. + +So it is a double free with a use-after-free in front of it, and the `-O1` and `-O3` counts +5ah recorded - three `malloc`s and four `free`s, then one and two - are LLVM working correctly +on IR that already had one block freed twice. + +#### The fix, and the one it repeats + +`mlir_ts::CastOp::getEffects` reported an allocation for exactly one shape: `ConstArrayType` +to `ArrayType`. That case was found the same way and for the same reason - CSE merging two +casts and aliasing what must be two distinct backing arrays - and the comment above it says so. +Casting to `AnyType` is the second allocating shape and was missed: `CastLogicHelper::cast` +routes every cast whose result is `any` into `castToAny`, which always calls `MemoryAlloc`. +Reporting `Allocate` and `Write` for it is the whole change. + +It is worth being clear about what this does *not* fix. Only a constant reaches the shape: two +reads of a runtime value are two `ts.Load`s, so the casts over them differ and CSE never had +anything to merge. Interning constant boxes into immortal globals - 5ah's preferred way out - +would therefore have hidden every case that exists today, which is exactly why it was the wrong +fix. The defect is an allocating op reporting itself as pure; who currently exploits that is a +detail. And it was inert under `gc` only by luck: a box is immutable and `any` equality unboxes +rather than comparing box pointers, so an alias is unobservable when nothing frees it. + +#### What it closed + +`00mixed_type_ops.ts` passes six runs in six in both tiers under `rc`, and is off the broken +list in `test/tester/CMakeLists.txt`. Suite 2,597/2,597. + +The other four are unmoved, and the two that wandered have moved in a way worth recording: +`13actions.ts` and `44toplevelcode.ts` now pass 30 runs in 30 in the JIT and still fail about +one run in six ahead of time. That is a narrower target than "one run in ten in both tiers", +but it is not a fix, so both stay disabled in both tiers until the cause is understood. +`00spread.ts` (5ag) and `nbody.ts` fail every run. + +#### Teeth + +Two cases in `00owned_any_boxing.ts`, one boxing the same string twice and one the same number, +each with a different value in between and a loop that allocates over whatever was freed before +the value is read back. Both were checked **individually** against a build with the fix stashed +out: 0 of 3 runs each, in both tiers. A third case, boxing one runtime value twice, was written +and then deleted - it passes without the fix, because two loads are not one value, and a test +that cannot fail is worse than no test. + +### 9.49 Step 5ai: the slot with no release, and therefore no retain + +`nbody.ts` is closed, and it took the two wandering files with it. One statement is the whole +bug: + +```typescript +let g: Holder; +function init(): void { g = new Holder(5.0); } +function main() { init(); churn(); assert(g.x == 5.0); } // fails under -mm=rc +``` + +Under `gc` and `none` that program is correct. Under `rc` the assert fails, because the +generated `init` is exactly this: + +```mlir +%5 = "ts.CallIndirect"(@Holder..new) {__owned_result} // +1 + "ts.Store"(%5, %0) // into the global - nothing taken + "ts.Release"(%5) // §9.30, end of the producer's block +``` + +The instance is stored into the global and then released, so the global is left addressing a +freed block. `main` reads a field out of it after something else has been allocated over it. + +#### Why it was missing + +`takeOwnershipOfLocal` excludes globals, and the reason it gives is true: *a global outlives +every scope*, so there is no scope exit to release from. But that answers only half the +question. Having no release does not mean having no retain - a global is a **root**, and a root +holds a reference for as long as the program runs. The exclusion dropped both halves, so a +store into a global neither took a reference nor gave one back, and `isOwningSlot` - the single +predicate the assignment path asks - did not name a global among the slots that hand the count +over. + +`isOwnedGlobalSlot` is the fix: a reference produced by `ts.AddressOf` whose element type owns +heap memory. The assignment path then behaves exactly as it does for an owned local - consume +an already-owned incoming value or retain it, and release what the slot held - with one +difference that is the point of the whole section: **nothing releases the last value, and that +is correct.** A global root's reference is given up when the process ends. Releasing the +outgoing value on an overwrite is safe from the very first assignment because a global with no +initializer is zero rather than undef (`ts.Default` lowers to `LLVM::ZeroOp`), and null is what +every release routine treats as nothing to do. + +#### What it closed + +`nbody.ts`, and both files that had been failing about one run in six: `13actions.ts` and +`44toplevelcode.ts`. All three now pass **forty runs in forty** ahead of time. That the two +wanderers were the same bug is what the wandering was: how far a program got before a dead +global showed depended on what the allocator handed back next, which is why they looked +intermittent while `nbody` failed every time. **An intermittent failure and a deterministic one +in the same list are worth trying against one fix before treating them as two.** + +`5ae` is down to `00spread.ts` alone. Suite 2,609/2,609, and the ownership verifier reports +nothing on any of the four files. + +#### Teeth + +`00owned_globals.ts`, five cases, each building the global in one function and reading it in +another with a churn between - a global written and read in `main` survives the bug, since +nothing releases until main ends, and that is what made the first hand-written reductions pass. +Checked individually against a build with the fix stashed out: the class instance and the +string fail, the two array cases and the reassignment pass. + +The array cases are kept deliberately even so. Without the fix nothing frees the array at all - +the heap copy a literal array is cast into is not a call result, so §9.30's end-of-block release +never claimed it, and it leaked rather than dangled - but a release into a global *without* a +matching retain would free a live array, and these are what would catch that. Reading `.length` +would fail in neither direction, since an array value is `{ data, length }` and the length +survives in the copy, so both cases go through an element. + +### 9.50 Step 5ag: two releases that had never had a retain + +`00spread.ts` is closed, and with it the broken list: **every file in the corpus now passes under +every memory model, in both tiers.** It took the two halves 5ag named, in the order it named them, +and both are the same shape - a release that had been running for a long time with nothing on the +other side of it. + +#### A generator's locals belong to its state object + +A generator's locals cannot live in its frame, because the state machine has to resume, so each +becomes a field of a heap state object. That object's release routine is generated from its type +and gives back every field that owns memory. `localTakesOwnership` excludes these locals for +exactly that reason - the frame is not their owner - and the exclusion, once again, took the +retain with it. The store into the object's field is where it belongs: it is a field gaining a +value, which is the debt `obj.f = x` carries, and `createLocalVariable` is where that store is +emitted. + +That alone fixes `00spread.ts`. It also breaks `00extension_cond_access.ts`, exactly as 5ag +predicted, which is the whole reason the order matters. + +#### What a capture box holds, and which reference that needs + +```typescript +function f(names: string[]) { return names.filter(x => x); } +function main() { for (const s of f(["asd", "asd1"])) print(s); } +``` + +`MLIRCodeLogic::CaptureTypeStorage` gives a read-write capture a `ref` field - the address of the +variable's cell - and gives everything else a field of the variable's own type, which +`CaptureOpLowering` fills by **dereferencing**: the box holds a copy of the value. Both kinds are +released when the box dies: `releaseCapturedFields` releases the cell for a `ref` field and the +value for any other owning one. + +`mlirGenResolveCapturedVars` decided which reference to take from a different question - whether a +reference to the variable could be *had*. Any obtainable reference got `ts.RetainCell`, so a +by-value capture retained the variable's cell while the box released the value. One fewer owner +than releases, and the value went when the box did, which is at the end of the function that built +the closure. For `f` that value is the source array of the generator it returns. + +The fix is to ask the question the box answers: `item.second->getReadWriteAccess()`, the same +predicate `CaptureTypeStorage` uses. A by-value capture now retains the value, exactly as the +branch below it already did for a captured value with no reference at all - and that branch's +comment, *"the box holds a copy, and a copy of a reference is a further owner of what it points +at"*, had been describing the rule the branch above it was breaking. + +#### What it closed + +`00spread.ts` and `00extension_cond_access.ts`, twenty runs in twenty each, in both tiers. +**`TSLANG_CORPUS_BROKEN_*` is empty**, and the suite is 2,617/2,617 with nothing disabled - the +first time that has been true since the corpus was registered in §9.42. The ownership verifier +reports nothing on any of the files involved. + +Not closed: 5z. A generator with a parameter, 500k iterations at `-O3` in the JIT, costs `rc` +41.0 MB against `gc`'s 15.9 and `none`'s 85.2 - and about 16 MB of every one of those is the +compiler itself, so `rc` reclaims roughly half of what the shape leaks without it rather than all. +The capture box is no longer the whole of that leak, but something still is. + +#### Teeth + +`00owned_generator_locals.ts`, four cases; the file fails against a build with both halves stashed +out at `-O0` (a wrong answer from the escaping generator) and at `-O3` (an access violation from +the spread). The spread case needed three things together to fail, and all three are in the file's +comment: the suite's own `--opt --opt_level=3`, an interpolated string built inside the callee, and +printing the result. **A freed block that nothing reuses reads back exactly as it did before** - +the first version of the escaping-generator case passed against the broken build for that reason +alone, and only failed once the other cases were allocating around it. + +### 9.51 Step 5ad: the exit code nobody returned + +An ahead-of-time build of + +```typescript +function main() { print(1); } +``` + +printed `1` and told the shell it had failed, under `-mm=rc` only. Recorded as an `rc` bug for +that reason, and it is not one. + +A TypeScript `main` that returns nothing lowers to `void @main()`. The C runtime that calls it +reads an exit code out of the return register regardless of what the signature says, so the +process's exit code was whatever the last instruction happened to leave there - zero under `gc` +and `none` by luck, and 1 under `rc`, where the last thing `main` does is give back a +reference. The entry point now lowers to `i32 @main()` returning 0, in every model, for anything +that is not a JIT run or a DLL. + +A `main` that returns a value is deliberately left alone and is still wrong in the same way: +`function main(): number { return 3; }` lowers to `double @main()`, which puts its result in +XMM0 and leaves the exit code exactly as undefined as before. That is a language question - whether +`main`'s result *is* the exit code - rather than a lowering one. + +#### The test that could not have caught it + +The suite has run ahead-of-time programs since long before any of this, and **it never looked at +what they returned**: the generated script ran the executable, captured stdout and stderr, and the +runner asked only whether `done.` appeared. So every `test-compile-rc-*` test passed against a +program that was telling the shell it had failed. The scripts now record `%ERRORLEVEL%` and the +runner fails a test whose program exited non-zero, saying so even when the output was right. + +Two more faults in the runner, both found by walking into them: + +- **Every `throw` of a string literal was uncaught.** The handlers catch `const std::exception &`, + so `throw "compile error"` reached `std::terminate` - a `__fastfail`, exit `0xC0000409`, + no message. That is what an ordinary failing test did (`checkedExecCommand` means to swallow it + and let the missing `done.` be the report) and what every command-line mistake did, including + a mistyped path. All of them are `std::runtime_error` now. +- **`-mm=gc` was not accepted**, so naming the default explicitly hit that same silent + `__fastfail`. It is accepted now, and gets its own cached script like the other two. + +**The cached scripts are why this needed two runs to verify.** `compile.bat` and its variants are +written once and reused, so a change to what they contain has no effect until they are deleted - +the first suite run after adding the exit-code line had 21 failures, all of them tests whose script +happened to be regenerated, and all of them reporting `exit code 0`, because `echo %ERRORLEVEL%` +writes a trailing space. + +### 9.52 Step 5z: the box an object owns and could not release + +A generator that takes a parameter leaked, and the reduction is three lines: + +```typescript +function* gen(n: number) { yield n; } +function main() { for (let i = 0; i < 500000; i++) for (const v of gen(i)) {} } +``` + +Ahead of time at `-O3`: `rc` 33 MB against `gc`'s 3.7 and `none`'s 57.6. Three blocks are +allocated per iteration and the loop frees one - the state object. The other two are the capture +box and the cell holding the parameter, and the LLVM IR says plainly that the free for the cell is +guarded by `icmp ne ptr %3, null` on a pointer that is never null, so it never runs. + +**An object's release routine could not see the one field of it that owns memory.** `.captured` +has type `ref>`, and a reference into storage is not ownership anywhere else in the +compiler - `getOrCreateReleaseRoutine` returns nothing for a `RefType`, so `releaseFields` +skipped it. The box, and every cell under it, outlived the object that was their only owner. + +`releaseFields` now routes a `ref>` field through the capture-box release routine +that already existed for closures - give back the cells, then free the box - and +`mlirGenObjectLiteralCaptures` takes the one reference that pays for it. Nothing else in the +language produces a field of that shape: `ref` is not spellable, so it is always the compiler's +own capture box. + +This is 5ag's original prescription, arriving one section late and for the reason 5ag did not give. +It was never needed to stop the double free - §9.50 was - and it is exactly what stops the leak. + +#### What it closed + +`function* gen(n) { yield n; }` at 500k iterations: **33 MB to 3.3 MB**, flat, below `gc`'s 3.7, +with `none` at 57.6 to show the allocation is real. The two shapes 5z originally named - a +generator with a parameter and a local, with an array local or a string local - were already flat +at 3.3 MB before this, closed by §9.50 without being measured. Suite 2,617/2,617. + +**No test holds this.** A leak is not an assertion, and the suite has nothing that fails on one. +The evidence is the measurement, and the guard against getting the pairing backwards is the corpus: +a retain without its release leaks silently, but a release without its retain frees the box while +the generator is still reading it, and that is what 2,617 tests would say. + +#### A measuring harness, at last + +> **The numbers from here to §9.61 are sampled, and understated - see §9.72.** This section +> replaced the JIT with a native-executable harness, but that first harness read the peak by +> spinning on `PeakWorkingSet64` while the process ran, which on `raytrace` reports less than +> half the true figure and varies by 20% between runs of one binary. The non-sampling script +> this section recommends did not reach the tree until `299eed65`, after §9.61. Conclusions in +> that range stand; magnitudes in it do not, and none of them can be compared with a number +> measured since. + +Every memory number before this was taken from the JIT, where ~13-16 MB of the measurement is +`tslang.exe` itself and the optimiser elides different things in different models - which is why +the same shape read 41 MB one hour and 12.6 MB the next, and why §9.31's numbers had to be +withdrawn in §9.43. `scripts/measure_memory_model.ps1` builds a native executable per model, runs it, and +reports peak working set: about 3.3 MB of floor instead of 16, no compiler in the process, and the +exit code checked - which means something as of §9.51. + +Sampling `PeakWorkingSet64` must not sleep between reads: the counter reads **zero** once the +process has exited, so a run that finishes between two samples is reported as 0 MB rather than as +small. + +**And do not sample at all.** The script above lived in a session scratchpad and was gone by +§9.64, which had to rebuild it - and the rebuild read 0.0 MB for every model on its first run, +because a program that finishes before the *first* sample defeats the no-sleep rule as +thoroughly as sleeping does. The kernel keeps the peak for as long as a handle to the process is +open, exited or not, so `GetProcessMemoryInfo` answers after `WaitForExit` with no loop and no +race. That is what `scripts/measure_memory_model.ps1` does now, and it is in the tree rather +than in a scratchpad for the reason this paragraph exists. + +### 9.53 Step 5af, first half: a call with no single callee still has an answer + +`raytrace.ts` reclaimed a quarter of what it allocated, and the reason is one line of §9.32's +reasoning taken further than it goes. + +`OwnedReturnConsumptionPass` consumes the reference a callee's return added, and to do that it +has to know that *this* callee retains. `calleeNameOf` therefore refuses a virtual call: the +identifier on `ts.ThisVirtualSymbolRef` names the declaration the call was written against, not +what the runtime class put in the slot, and consuming a reference an override never took frees +live memory. The same refusal covered interface dispatch, which does not even reach that code. + +**But the question is answerable for a set.** A virtual call has no single callee and it does have +a set of possible ones, and "does every candidate return owned" is exactly as safe as "does this +callee return owned". Two candidate sets, one per dispatch shape: + +- **A class vtable slot:** every method in the module whose name after the last dot matches the + call's. An override is `.` by construction, so this is a superset, and a + superset is the safe direction - it costs precision only where two unrelated classes share a + method name and disagree, and the cost there is a leak. +- **An interface slot:** the member name is *not* enough, because an interface method can be + implemented by an object literal, whose function is named for where it was written + (`Surfaces..feL166C18FH19436811`) rather than for the member it fills. Matching on the name + would miss it, and a missed candidate is the direction that frees memory nobody retained. The + vtables say it exactly: a class implementing `Thing` gets `Sphere.Thing..vtbl`, an object + literal gets `Thing...vtbl`, and the interface's name is a whole dot-separated component + of both. The candidates are that slot in every vtable global naming the interface, and a slot + that is absent - not initialised from a symbol - makes the call unclassifiable rather than being + skipped. + +#### What it closed + +| shape | before | after | `none` | +| --- | --- | --- | --- | +| a method returning a new instance, 300k calls | 10.7 MB | 0.6 MB | 10.6 MB | +| a method returning an object literal as an interface | 10.7 | 0.6 | 10.6 | +| the same through an interface-typed variable | 10.6 | 0.6 | 10.6 | +| `raytrace`'s `intersections` loop alone | 11.5 | 0.6 | 12.1 | +| **`raytrace.ts`** | **82.9** | **43.9** | **117** | + +Reference counting now reclaims about **62%** of what `raytrace` leaks without it, against 29% +before. Suite 2,617/2,617, and an ownership-verifier sweep over 200 corpus files reports what it +reported before the change - two findings in `00break_continue_scope_exit.ts`, confirmed +pre-existing by rebuilding with this pass stashed out. + +The remaining 43.9 MB is still the largest thing open, and it is spread rather than concentrated: +cutting the reflection recursion out of `shade` leaves 42 against `none`'s 65, and cutting the +natural-colour closure instead leaves 27 against 40. Both keep about a third, which is the shape of +something every path does rather than one site. + +### 9.54 A call through a value, and the question the module answers + +The shape §9.53 left is the one `calleeNameOf` calls unanswerable outright: a call through a +plain value - a callback handed to `reduce`, a function-typed field, a callee chosen by a +condition. There is no identifier, no vtable, and no member name to ask about. + +There is still an answer, and it is about the module rather than the call: **if every function here +that hands back a heap value retains it first, then every call that returns one hands back a +reference, whatever it dispatched to.** That is the +1 convention of §9.24 stated over the whole +module. One unclassified function anywhere - including an external one, whose returns cannot be +seen at all - switches it off for every indirect call, which is the conservative direction. + +```typescript +function makeA(k: number): Box { return new Box(k); } +function makeB(k: number): Box { return new Box(k + 1.0); } + +for (let i = 0; i < 300000; i++) { + let f = (i % 2 == 0) ? makeA : makeB; // no callee to name + let b = f(1.0); + sink += b.v; +} +``` + +`rc` 10.6 MB before, 0.7 after, against `none`'s 10.6 - checked against a build with the rule +stashed out, which is the only way to attribute it. Suite 2,617/2,617. + +**It is worth almost nothing on `raytrace`**: 43.9 MB to 43.5. The rule fires there - `reduce` +and `getNaturalColor` both consume their results afterwards - and the memory does not move, which +says those particular references were not what that program is holding. It is kept for the class it +closes rather than for the number, and the number is recorded here so the next reader does not +re-run the experiment. + +#### Where raytrace's remaining 43 MB is not + +Enough has been ruled out to be worth writing down. Replacing `reduce` with a hand-written loop +over the same closure makes it **worse** (79.1 MB), so `reduce` is not it. Cutting +`getNaturalColor` out entirely leaves 5.7 against `none`'s 40 - 86% reclaimed, against 63% for +the whole program - so what is left is inside that function, which per call builds a closure over +six captured parameters, six heap cells to hold them, a capture box, and a colour per light. The +per-shape benchmarks for each of those pieces come back flat under `rc`, so it is their +combination, or their sheer number, rather than any one of them. + +#### A compiler bug found on the way, unrelated to any of this + +```typescript +function f(a: Color): Color { + const g = (k: number) => { return new Color(a.r + k); }; // error + return g(1.0); +} +``` + +*'ts.Load' op using value defined outside the region*, in every memory model. Reading a captured +variable inside the arguments of a `new` expression fails MLIR's region isolation; hoisting the +same expression into a local first compiles. Nothing to do with reference counting - it is what +stopped two of the benchmarks above from being written the obvious way. + +### 9.55 Discovery has to walk `new`'s arguments (the bug above, fixed) + +A lambda captures what its body reads, and what its body reads is established by the discovery +pass - the `dummyRun` in `mlirGenFunctionLikeDeclaration`, whose `resolveIdentifierAsVariable` +fills `passResult->outerVariables` every time it resolves a name that lives outside the function +being discovered. Anything that pass never visits contributes no captures. + +`NewClassInstance` had a shortcut for exactly that pass: + +```cpp +if (genContext.dummyRun) +{ + // just to cut a lot of calls + newOp = builder.create(location, classInfo->classType, builder.getBoolAttr(false)); + return newOp; +} +``` + +It returns before `evaluateProperty(CONSTRUCTOR_NAME, ...)` - which is the expensive part it means +to cut - but it also returns before `mlirGenOperands(arguments, ...)`, so **the constructor +arguments were never walked at all**. A variable read only inside them was never registered as +captured, the lambda's real body then read the enclosing function's own value, and `ts.Func` is +`IsolatedFromAbove`, so the module failed to verify. + +The fix keeps the shortcut and walks the arguments anyway, discarding both the values and any +errors (the errors are the shortcut's own - no constructor resolved, no receiver types - and +discovery is best-effort by construction). The ops the walk creates land in the throwaway dummy +function. + +Two things this says beyond itself: + +- **A "just to cut calls" shortcut in the discovery pass is a semantic decision, not a + performance one.** Discovery is the only place captures are found, so anything skipped there is + not slower, it is missing. The sibling paths were checked: `NewClassInstanceByCallingNewCtor` + (interface and construct-signature `new`) and `NewArray` both walk their arguments + unconditionally; the class path was the only one with the gap. +- **The control case is what makes the test a test.** Reading the captured variable anywhere else + in the same lambda - one extra `let seen = a;` - registers the capture and the `new` arguments + then compile fine. So a test whose lambda touches the variable twice proves nothing, and + `00capture_in_new_arguments.ts` reads each captured thing *only* inside the `new`. Against the + unfixed compiler 8 of its 9 cases fail; the 9th is that control. + +Verified across `gc`, `rc` and `none`, at `-O0` and `-O3`. Suite 2,623/2,623. + +### 9.56 An awaited result travels through a slot, not through `async.value` (5ab) + +5ab said "passing an argument to an awaited async function does not compile". That was wrong about +the cause, and wrong about which programs it stops. The condition is the awaited function's +**result type**: + +| result | before | +| --- | --- | +| `i32`, `i64`, `f32` | compiles | +| `number`, `string`, `boolean`, a class, an array | `failed to legalize operation 'async.runtime.load'` | + +Arguments never mattered. `await withDefault()` compiled and `await twice(3.0)` did not, and what +separates them is that one returns an `i32` and the other a `number` - `00async_await.ts`'s +`f(a = 1)` happens to return the literal's `i32`, which is the only reason the suite had a passing +async test at all. + +**Root cause.** `await` built an `async.execute` whose result was the awaited expression's type, +so the value travelled as `!async.value`. In `transform.cpp` the pass order is +`createConvertAsyncToLLVMPass()` **then** `createLowerToLLVMPass(compileOptions)` - MLIR's async +conversion runs first, and it converts an async value's payload with its own `LLVMTypeConverter`, +which has none of the TypeScript conversions that `populateTypeScriptConversionPatterns` adds to +the later pass. A builtin payload passed straight through; anything from this dialect had no +conversion and `RuntimeLoadOpLowering` refused it. + +**The fix.** A token carries no payload, so give the async value nothing to convert: the awaiting +function allocates a slot, the region stores into it, and `async.await` on the token is what orders +that write before the read. Outlining already passes values the region uses from above in as +arguments, so the slot needs no special handling. `!async.value` no longer appears in anything +this compiler emits. + +**And under `rc` the slot has to own what it holds.** Everything the awaited expression produced is +a temporary of the region's block, so §9.50's end-of-block release frees it as the region ends - +before the awaiting function loads the slot. Same two cases as a local's declaration: a value that +already carries a reference hands it over (`__owned_consumed`), anything else is retained, and the +awaiting scope gives that reference back. The attributes go on only if the store actually +happened, because an owned slot is released whatever is in it and a slot nothing wrote holds +whatever the frame held before. + +Worth keeping from this one: + +- **The reported condition of a bug is a hypothesis, not a datum.** "Passing an argument" was + recorded from two programs that differed in more than one way. Ten minutes of a type-by-type + table said the argument had nothing to do with it - and the fix for what it actually was is + unrelated to arguments entirely. +- **`-O0` correct and `-O3` garbage is not an optimiser bug, it is a lifetime bug.** The class case + printed the right answer at `-O0` with the release already in the wrong place; nothing had + reused the block yet. The test allocates over it on purpose - the standing rule that a freed + block keeps its contents until something else takes it, applied to an await. + +`00async_result_types.ts` covers each result type, both directions of the argument question, an +await inside an async function, and three results read after a churn loop. Suite 2,629/2,629. + +### 9.57 The collector was allocating without a lock (5ac) + +`-mm=gc` faulted on a long chain of awaits - about 1 run in 4 at 200k coroutine frames, never at +50k, never under `rc` or `none`. It had been read as something about collection, and it was not. + +**Evidence, in the order it narrowed:** + +| probe | result | +| --- | --- | +| `rc` and `none`, 200k awaits | 0 failures in 6 each - `gc` only | +| `GC_INITIAL_HEAP_SIZE=1G` (nothing needs collecting) | unchanged, 2 in 8 | +| resume the coroutine inline instead of on the pool | 0 in 10 | + +A heap large enough that no collection happens changes nothing, so the collector is not reclaiming +a live frame; taking the pool thread away fixes it, so the second thread is the whole story. + +**Root cause.** Boehm does not lock its allocator until it is told the program is multi-threaded. +From `include/private/gc_locks.h`, `set_need_to_lock()` is `GC_need_to_lock = TRUE`, and until +that runs `LOCK()`/`UNLOCK()` expand to nothing. Nothing had run it: the worker threads are +`llvm::DefaultThreadPool`'s plain `std::thread`s, created by neither `GC_CreateThread` nor +anything else the collector knows about. So a worker freeing a coroutine frame and the awaiting +thread allocating walked the same free lists at once, unlocked. + +`GC_allow_register_threads()` is the call that sets it (`win32_threads.c`: `GC_start_mark_threads()` +then `set_need_to_lock()`), and it is also the permission a thread needs before it may register +itself. Both halves are wanted, and they are separate: **adding the call alone took 200k awaits +from 1 failure in 4 to none in 32, with the workers still unregistered.** Registration is the +other half - an unknown thread is not suspended during a collection and its stack is not scanned, +so a frame held only in that worker's registers can be freed underneath it. + +**Where the call goes.** In `injectInit`, beside `GC_init`, because an ahead-of-time build links +the collector's own `GC_init` and there is no hooking that; the GC pass runs only for `-mm=gc`, +which is exactly when this is wanted. `GC_enable_threads` is the name, defined in both copies of +the async runtime - the one inside `TypeScriptRuntime.dll` for the JIT and +`TypeScriptAsyncRuntime.lib` for AOT - over the shared `AsyncGCThreads.h`. + +Two things this cost, and both are worth keeping: + +- **A first attempt broke every `rc` and `none` await.** The workers registered themselves guarded + by `GC_is_init_called()`, which looks like "is this a `gc` program" and is not: the collector + initializes itself on first use, so it answers yes in a program that never meant to collect + anything - and `GC_register_my_thread` then aborts, because `GC_allow_register_threads` had not + run. The guard has to be **our own flag, set by the call we make**, never the collector's idea of + whether it woke up. Caught because the new test runs under all three models; a `gc`-only test + would have shipped it. +- **The runtime is two files, not one.** `lib/TypeScriptRuntime/AsyncRuntime.cpp` and + `lib/TypeScriptAsyncRuntime/AsyncRuntime.cpp` are near-duplicates, JIT and AOT respectively. + Fixing one leaves the other, and the suite says so - the JIT tier passed while `test-compile-*` + failed with `0xC0000005`. + +**One more thing the injected call cost, and it is worth stating plainly.** A JIT run resolves +external symbols from the export tables of what is loaded, and `GC_enable_threads` lives in +TypeScriptRuntime.dll - which a JIT run only has if it was passed with `--shared-libs`. The call +is in `main`, so for one build **every** bare `tslang --emit=jit` under `gc` failed to +materialize, async or not. The suite never saw it: its runner passes the DLL. `runJit` now stands +the symbol in when nothing else provides it - and the stand-in only lets the program run, it does +not carry the fix, so a bare JIT is still exposed to the race exactly as it was before. Two +mechanisms had to be told apart to place it: `DynamicLibrary::AddSymbol` is found by +`SearchForAddressOfSymbol` but **not** by the process generator the JIT resolves through, so the +obvious placement compiles, looks right, and changes nothing; the definition has to go into the +JITDylib's `absoluteSymbols` map beside the CRT overrides. And it must be conditional, because a +JITDylib definition beats a generator - an unconditional one would shadow the real +`GC_enable_threads` whenever the DLL *is* present. + +`00async_gc_threading.ts` is 250k awaits that allocate on both sides, so the two threads are in +the allocator together rather than taking turns. Against the unfixed runtime it faults 7 runs in +20 at `-O3` and 6 in 10 at `-O0`; smaller shapes are much weaker (60k iterations: 2 in 20). It is +a race, so it is a rate - but it is a rate that two tiers sample on every run. Suite 2,635/2,635. + +### 9.58 `for...of` over a literal array does not leak (5aa) + +5aa said `rc` held about a tenth of what iterating a literal array allocates: 15.6 MB at 900k +iterations against `gc`'s 4.1 and `none`'s 155.9. Re-measured on the AOT harness (§9.52), with the +default library, at `-O3`: + +| iterations | rc | gc | none | +| --- | --- | --- | --- | +| 300k | 0.7 | 2.8 | 49.0 | +| 900k | 0.6 | 2.6 | 145.2 | +| 3M | 0.7 | 2.8 | 482.3 | +| 900k, strings | 0.7 | 2.7 | 145.2 | + +Flat across a tenfold range while `none` climbs to 482 MB, and **below `gc`** at every size, which +is what a reference count should look like on a value nothing outlives. Whether some later slice +closed it or the 15.6 MB was an artifact of the JIT method cannot be told apart now; either way +the figure goes with the rest of the withdrawn ones. + +Two notes for whoever measures next: + +- **`--no-default-lib` makes this shape measure nothing.** Without the library the optimiser + elides the whole loop and `none` reports 0.7 MB - the same as `rc`, and equally meaningless. The + harness now takes `-WithDefaultLib` for cases like this. It is the standing rule in its + sharpest form: **if `none` is flat, there is no benchmark here.** +- The same rule kills the object version - `for (const p of [new P(1), new P(2)])` elides under + every model, `none` included, so it says nothing about ownership. + +### 9.59 What raytrace's remaining 43.5 MB is, measured rather than guessed (5af) + +Two things measured on the AOT harness, both of which narrow 5af without closing it. + +**It is a constant fraction, not a fixed set.** Rendering the same scene at four sizes: + +| pixels | rc | gc | none | rc / none | +| --- | --- | --- | --- | --- | +| 64x64 | 3.4 | 2.7 | 8.0 | 0.43 | +| 128x128 | 11.5 | 2.8 | 29.9 | 0.38 | +| 256x256 | 43.5 | 1.3 | 117.0 | 0.37 | +| 512x512 | 172.4 | 2.9 | 465.8 | 0.37 | + +`rc` grows exactly with the pixel count and holds a flat **37% of everything the program +allocates**. So this is not a set of objects retained once; it is a share of every pixel's work - +some allocation site, or class of them, that is never released at all. + +**It is not the closure.** §9.54 recorded that a hand-written loop over the same closure is worse +(79.1 MB) and left the closure itself under suspicion. Replacing `addLight` with an ordinary +method taking its six captures as parameters - no closure, no capture box, no cells, same +arithmetic - gives **`rc` 43.5 MB, unchanged to the decimal**, while `none` falls 117 → 88.2. So +the closure accounted for a quarter of what the program allocates and **none** of what it holds. +Reference counting reclaims closures here exactly as it should. + +That leaves the per-pixel object traffic: `Vector` and `Color` results, the `{ start, dir }` ray +literals, and the `Intersection` object literals that `intersect` returns through an interface. +The obvious next measurement - make `Intersection` a class and see what moves - **cannot be taken +yet**: that two-line edit segfaults the compiler (5aj). + +### 9.60 A return written inside an `if` (5af, 5aj) + +> **Both numbers below are sampled ones - see §9.72.** Every figure in §9.52 through §9.61 was +> taken with the spin-on-`PeakWorkingSet64` harness, which reports less than half the real peak +> on `raytrace` and wanders between runs. The fix here is real and the shape of the improvement +> is real; the magnitudes are not. Measured with the in-tree script, `raytrace` at this commit is +> **9.2 MB**, and 8.9 today. Do not use 6.2 or 43.5 as a baseline without re-measuring. + +`raytrace` held 43.5 MB against `gc`'s 2.8 and had done for the whole arc. It is **6.2 MB** now - +95% of what `none` leaks, reclaimed - and the whole of the difference was one shape. + +**The bug.** §9.30 releases a value nothing receives at the end of the block that produced it. +The end of the block is not the only way out of it: a `return` written inside an `if` leaves from +a nested region and never reaches that release. `releaseDiscardedTemporaries` scanned only the +value's own block for an exiting op, found the tail `ts.ReturnVal`, and put the release there - +correct for the path that falls through and nothing at all for the path that returns. + +`getNaturalColor`'s inner function is exactly that shape, and it runs per light per pixel: + +```typescript +let neatIsect = this.testRay({ start: pos, dir: livec }, scene); // a ray, nothing receives it +let isInShadow = (neatIsect === undefined) ? false : (neatIsect <= Vector.mag(ldis)); +if (isInShadow) { + return col; // ray lost here +} +``` + +The fix walks the ops that follow the definition in its block and puts a release before every +`return` nested inside them. Returns only, and the walk is its own argument: everything it reaches +is inside a **sibling** of the definition, so a `break` or `continue` found that way targets a +loop that does not contain the definition - control comes back into the block and runs the +end-of-block release too, and releasing at both would give the same reference back twice. A +return leaves for good wherever it is written. (The reverse case - a temporary in a loop *body* +whose iteration ends in a `break` - still leaks; distinguishing the two needs the jump's target +loop rather than its position, and a labelled `break` can leave more than the nearest one. Filed +as 5ak.) + +**How it was found, which is the part worth keeping.** By measuring, one cut at a time, on the +program itself rather than in miniature. Every synthetic version of these shapes came back flat - +the optimiser elides an allocation whose escape it can see, so `none` reported the same 0.7 MB as +`rc` and the benchmark measured nothing. Cutting the real program does not have that problem: + +| variant | rc | none | +| --- | --- | --- | +| whole program | 43.5 | 117.0 | +| the `addLight` closure replaced by a plain method | 43.5 | 88.2 | +| `Intersection` a class instead of a literal through an interface | 43.5 | 117.0 | +| `Ray` a class instead of a literal | 50.0 | 121.4 | +| shading math removed, shadow test kept | 42.3 | 87.2 | +| shadow test removed, shading math kept | 5.7 | 40.1 | +| the shadow test's result bound but never used | 5.7 | 73.5 | +| the shadow test's result used inline, not bound | 5.7 | 73.5 | +| bound, used, and an early `return` under it | 23.5 | 73.5 | + +The first three rows are what the item had been suspecting for three sections, and each of them +moved `none` without moving `rc` at all - which is the signature of "allocates, but not what +leaks". The last three isolate it to one line, and to the `return` rather than the value: the +same call with the same binding leaks only when something returns out of the block afterwards. + +Two general things: + +- **`rc` holding a constant FRACTION of what a program allocates, flat across problem sizes, is + what a missing release on a common path looks like.** §9.59 measured 37% at every image size and + read it as "a share of every pixel's work"; that was right, and it was one line. +- **When `none` does not move, the change is not about allocation.** Three of the rows above are + perfectly good programs that answer a question nobody asked. + +**5aj, found on the way and fixed first, because it blocked the third row.** The return statement +reported "No return value" and then carried on to cast and retain it - and reading a null +`mlir::Value`'s type faults with no diagnostic at all (exit `0xC0000005`, silence). A discovery +pass reaching there is ordinary: a return expression can depend on something not registered yet, +and the statement loop comes back for it. So the report has to be conditional and the failure +unconditional; it was the other way round. Fixing that turned the crash into a proper diagnostic, +which then pointed at a second gap of §9.55's kind - a call whose callee cannot be resolved during +discovery returned a placeholder **without walking its arguments**, so `scene`, read nowhere else +in that closure, was never captured. Same shortcut, same fix, second place: `new` was the first. + +**One change in that commit is defensive and did not earn its place by measurement, which is worth +saying rather than leaving implied.** `mlirGenReleaseOwned` and `mlirGenDisposable` ended their +outward walk at the first scope with no list of its own; that guard is the wrong shape - a scope +that owns nothing still stands between a `return` and the scopes that do - so it was hoisted out. +No program has been found that needs it. Every block scope is given a list when it is created, so +the walk was reaching the enclosing scopes anyway: an owned local plus a `return` out of an `if` +measures 0.6 MB against `none`'s 13.9 either way, and `raytrace` is 6.2 either way. + +The first control run said otherwise - 13.9, reclaiming nothing - and it was wrong: it disabled the +outward walk **entirely** rather than restoring the original nesting, which is a strictly worse +build than the bug. Restoring the actual original is what gave the identical numbers. **A control +has to be the thing it claims to be**; "turn the fix off" and "put the bug back" are not the same +edit, and the difference here was the whole result. + +Suite 2,641/2,641, corpus under all three models in both tiers - which is the guard against the +new release being a second one. `00owned_early_return.ts` covers the shapes; a leak cannot be +asserted, so the cases build over the memory they might have freed and read it back. + +### 9.61 Which jumps leave the block (5ak) + +§9.60 covered `return` and deliberately left `break` and `continue` alone, because whether one of +them leaves the block holding a temporary decides between a leak and a double release, and +position alone does not answer it. It is answerable, so here it is answered. + +**Both directions are real.** A discarded temporary in a loop *body* whose iteration ends in a +`break` is lost - the release at the end of that body never runs for that iteration. Measured +with a maker behind an interface so the optimiser cannot elide it, one iteration in four ending in +a break: **13.9 MB against `none`'s 53.2**, and 0.6 after. But a temporary in the block that +*contains* the loop is a different case entirely: the `break` leaves only the loop, control comes +back, and the end-of-block release runs - a release at the jump as well would give the same +reference back twice. + +**The question, and how it is asked.** Walk from the jump outwards to the op that sits in the +temporary's block, asking at each level whether it catches the jump: an unlabelled `break` is +caught by the nearest enclosing loop or `switch`, an unlabelled `continue` by the nearest +enclosing loop, and a labelled one by whatever carries that label - which may be several levels +further out. Nothing on the way means the jump is caught beyond the block, so the block is inside +the loop being left. That last part is why position cannot stand in for it: +`outer: while (..) { while (..) { break outer; } }` leaves two loops from inside one. + +**How it is verified, given that a double release is invisible at run time.** By reading the IR +rather than the numbers: the count of `ts.Release` in each shape says which way the predicate +answered. + +| shape | releases | +| --- | --- | +| temporary in the loop body, `break` | 2 - one at the jump, one at the end of the body | +| temporary in the loop body, `continue` | 2 | +| temporary in the inner body, `break outer` | 2 | +| temporary above the loop, `break` below it | 1 - end of block only | +| temporary above the loop, `continue` below it | 1 | + +Those five are in `00owned_early_return.ts`, and the last two are the ones that matter: a test +cannot observe the double release they guard against - the second decrement reads a freed block's +refcount and usually just returns - so the IR count is the evidence, and the corpus under `rc` and +`none` in both tiers is the backstop. Suite 2,641/2,641. + +### 9.62 The two findings the verifier had been reporting all along + +`--verify-ownership` had two standing findings, both in `00break_continue_scope_exit.ts`, +confirmed pre-existing when they first appeared (section 9.53) and left open since. They are the +two nested `using` scopes - `bothScopes`, and the labelled `labelledContinue`: + +``` +00break_continue_scope_exit.ts:68:19: error: ownership: this slot takes a reference that some +path out of the function never gives back +``` + +**What the path is.** A scope's cleanup region runs while an exception is already unwinding. The +disposal in it is a call, and `TryOpLowering` marks every call in a scope's body with that scope's +landing pad - which reaches into the cleanup regions of the scopes nested inside it, because those +sit in its body. The inner cleanup's `[Symbol.dispose]()` therefore became an invoke unwinding to +the *outer* cleanup, and the `ts.ReleaseSlot` written after it was stepped over. The outer cleanup +releases its own slot and knows nothing of the inner one, so the reference is gone: + +``` +^bb8: // the inner scope's cleanup + ts.Invoke(dispose, inner)[^bb9, ^bb12] // ^bb12 is the OUTER cleanup +^bb9: + ts.ReleaseSlot(inner) // not on the ^bb12 edge +^bb12: + ts.CallInternal(dispose, outer) + ts.ReleaseSlot(outer) // and never inner +``` + +**The fix is to remove the edge, not to add a release.** `^bb12` above is the function's outermost +cleanup, and it has always used a plain call - nothing encloses it, so there was no landing pad to +mark it with. Every cleanup now agrees with the one that was already right: a call written inside +a nested cleanup region is skipped when a scope marks its body, so it stays a plain call. +`isInsideNestedCleanupRegion` in `LowerToAffineLoops.cpp` is the whole change. The price is the +C++ rule - a disposal that throws while unwinding terminates instead of continuing outwards - and +it is a price only in principle: throwing from a `[Symbol.dispose]()` does not work at all today. +A single, un-nested `using` whose disposal throws fails to JIT on a missing `??_7type_info@@6B@` +in every model, which is why no test could be written for the path this fixes. + +> **The premise is false — see §9.65.** A throwing disposal with something to catch it runs fine; +> the case that produced this claim had nothing to catch it, and a plain `throw 1` with no `using` +> gives the identical exit. `??_7type_info@@6B@` appears nowhere. So the price above is real and +> observable — the disposal terminates the process at `0x80000003` — and it diverges from TC39's +> `SuppressedError` semantics. Still a defensible choice; it was just not a free one. + +**The first attempt was the obvious one and it was wrong.** Wrap the cleanup's disposals in a +catch-less `TryOp` of their own whose cleanup gives the references back - correct by construction, +and it silenced the verifier. It also failed 24 tests. A `TryOp` nested inside a `TryOp` is the +construct section 9.11 records as already broken, and `00try_using_catch.ts`'s own comment says +so in as many words. **The machinery a fix wants to reuse may be the machinery a known bug is +about**; the test that fails will say so, but only after the change is built. + +**Two adjacent defects, read off the same IR and deliberately not fixed here.** Both are about the +cleanup region standing in for a scope exit it cannot see the progress of, and neither is a +refcount question: + +- The cleanup's landing pad is also the unwind target of the *body's* disposal, so a disposal that + throws in the body is followed by the cleanup disposing the same variable a second time. +- It is the unwind target of the `using` initializer's own `new` as well, so a constructor that + throws leaves the cleanup disposing a slot nothing was ever stored into. + +Both are fixed in section 9.63, and both turned out to be testable - the "no test could be +written" above is about the path *this* section fixes, not about those two. + +**The verifier now runs in `ctest`.** Both of its real findings - the break/continue scope-exit bug +of section 9.18 and this pair - came from a sweep run by hand that nothing repeated, which is why +this pair sat open as long as it did. `verify-ownership.cmake` is that sweep, over every corpus +file, in eight shards of about four seconds: `test-ownership-verifier-0..7`. It is the only check +in the suite that reads the IR rather than the program's output, and that is exactly why it earns +its place - a reference nobody gives back changes no answer, so nothing else here can see one. +Suite 2,641 -> 2,649, all green. + +### 9.63 How far the block got (the two defects 9.62 left open) + +Section 9.62 read both of these off the IR, named them, and left them: a cleanup region standing +in for a scope exit whose progress it cannot see. Both are now fixed, and both turn out to be +observable, which 9.62 did not expect. + +**They are not the path 9.62 could not test.** That section closed with "no test could be written +for the path this fixes", because a `[Symbol.dispose]()` that throws while unwinding fails to JIT +on a missing `??_7type_info@@6B@`. That is true of the path *9.62* fixed - a disposal inside a +nested cleanup region. Neither of these two is that path. One needs a constructor that throws and +the other a disposal that throws on the **normal** exit, and both of those work today: + +| | before | after | +| --- | --- | --- | +| `using r = new Boom(true)`, constructor throws | `0xC0000005` | disposes nothing, throw reaches the caller | +| `[Symbol.dispose]()` throws on normal exit | `0x80000003` | disposes once, throw reaches the caller | + +The first reads a vtable out of whatever the frame happened to hold, because the cleanup disposes +a slot the initializing store never reached. The second is the double disposal: the body's own +disposal is a call in the try body, so it unwinds to that same cleanup, and the cleanup disposes +the very same variable again - the second throw arriving while the first is still unwinding, which +terminates. + +**One guard answers both, because they are one question asked twice.** A boolean beside the +hoisted slot, declared in front of the `TryOp` with the slot so the cleanup can see it, set false +there and true only after the initializing store; every disposal reads it, clears it, and disposes +only if it was set. Cleared **before** the call rather than after it, which is the whole of the +first defect - after the call is never reached when the call is what threw. `mlirGenDisposeOne` is +the change; a `using` in a plain block has no `TryOp`, no cleanup, no second visitor, and so no +guard. + +``` +%5 = ts.Variable() // the using slot, hoisted +%7 = ts.Variable(false) // its guard, hoisted beside it +ts.Try { + %11 = call @Boom..new; call @Boom.constructor // throws here -> cleanup, guard still false + ts.Store(%11, %5) + ts.Store(true, %7) // armed only now + ... + ts.If(load %7) { store false, %7; call dispose } // throws here -> cleanup, guard now false + ts.ReleaseSlot(%5) +} cleanup { + ts.If(load %7) { store false, %7; call dispose } + ts.ReleaseSlot(%5) +} +``` + +`ts.ReleaseSlot` needs no guard and gets none: the reference is owed exactly once whichever way +the block is left, and an owned local hoisted in front of a `TryOp` already starts as null under +`rc` (`VariableOpLowering`), which the release routines treat as nothing to do. The two debts are +different debts - a disposal is a call the program wrote, a release is a count - and only one of +them is idempotent in the wrong direction. + +**`00using_unwind_progress.ts` is the test, and every case in it was checked against a build with +the fix taken back out** - not with the fix switched off, which 9.60 records as a different and +worse build, but with the three files restored to their committed state. Six cases, three models: + +| case | control build | with the fix | +| --- | --- | --- | +| normal exit disposes once | passes | passes | +| throw after the declaration, cleanup disposes | passes | passes | +| constructor throws | `0xE06D7363` / assert | passes | +| second of two constructors throws | `0x80000003` / assert | passes | +| disposal throws | `0x80000003` | passes | +| the same in a hand-written `try` body | `0xE06D7363` / assert | passes | + +The first two are controls in the strict sense: they are what a fix that simply skipped the +cleanup would break, and they pass on both builds. The fourth is why "skip everything" is not the +fix - one declaration completed and owes a disposal, the next never existed. And the `none` column +is the reason to run all three models rather than one: where `gc` and `rc` crash, `none` reaches +the assertion and names the case, because an uninitialised slot holds something different under +each allocator. Same bug, three faces - the pattern of section 9.55. + +Suite 2,649 -> 2,655, all green, ownership verifier included. Six tests: the file's four named +entries, plus the two ahead-of-time corpus entries the loop generates for it under `rc` and +`none` - its JIT entries under those models are named already, so the loop skips them. + +### 9.64 Closing 5o by measuring it rather than by writing more of it (5o) + +5o was left open on 2026-09-04 with three specific claims. All three are now false, two of them +because §9.46 and §9.53 closed them the day after the item was written and nobody went back to +its text. This section is the measurement that retires it. + +**The instrument.** A counter in `OwnedReturnConsumptionPass` over every call whose result owns +heap memory: how many are refused, and which of the four answers refused them - a named callee, +the closed-world rule, the virtual candidate set, the interface candidate set. Plus an upper +bound, `virt_exact_would_pass`: would this virtual call pass if the candidate set were exactly +the declaration named on the ref? That is what a *perfect* override set could buy, and it is +deliberately not sound - it is a ceiling, not a proposal. Swept over all 261 corpus files that +compile alone at `--opt --opt_level=3 -mm=rc`. + +| 5o's claim | measured | +| --- | --- | +| "it buys 2.6% of `raytrace`" | **0%.** `raytrace` refuses 0 of its 80 owning calls | +| "doing it properly needs the callee's override set" | worth **2 calls in the whole corpus**, both cross-module | +| "every `new C(...)` through such an interface leaks one instance" | does not reproduce; §9.53's interface half covers it | + +The third was checked on the harness rather than on the text: a virtual method returning a new +instance, an interface method returning one, and `new C(...)` through a constructor interface all +measure **4.1 MB against `none`'s 13.4** at 300k iterations, which is `gc`'s 5.7 beaten rather +than matched. + +**The candidate-set worry was the wrong worry.** §9.53 chose a superset - every method in the +module sharing the call's member name - and recorded the precision cost as "two unrelated classes +share a method name and disagree". Three attempts to build that case all failed to leak, each for +its own reason worth knowing: a call with no override is not virtual at all and never consults the +set; a method returning a field still retains on the way out, so it *does* return owned; and a +generator method's wrapper returns `new `, so it classifies as owning too and the +generator exclusion never reaches it. The ceiling column then said why - across 261 files a +precise override set would change **two** decisions. + +**What is actually left is cross-module, and it is not a classification problem.** Every virtual +and interface refusal in the corpus is in an `import_*` file - eleven virtual, five interface, +zero anywhere else: + +``` +ts.Func @M.Animal.speak !ts.func<...> { +} {export, sym_visibility = "private"} // imported: no body to read +``` + +`functionReturnsOwned` declines an empty body, so one imported method poisons the candidate set +for its whole member name and every `.speak()` in `import_class_extends.ts` keeps its string. +An imported tslang function is distinguishable from a foreign one - it carries `export` where a +`declare`d C function carries nothing - so the *classification* is easy. What is not easy is that +consuming its result is only sound if the module that defines it was built reference-counted, and +that is §4's question, not this one. §9.7 settled the policy for the case it can see: a DLL +carries `__tsmm__...`, a mismatch warns, and the agreed answer is to allow the link and +leak rather than double-free. A statically linked second module carries no marker at all, because +the import is resolved by re-parsing that module's *source* before any artifact of it exists. + +So the residue is filed where it belongs rather than left under 5o: + +5al. **A virtual or interface call on an imported class is never consumed.** Sound to fix only + once a link can guarantee both sides were built reference-counted; today it cannot, and §9.7's + agreed policy is to leak across a mixed link rather than risk a double free. Making a mixed + static link *fail* would make it sound, and that reverses a recorded decision, so it is a + question rather than a task. Dominated in any case by the same section's larger instance: the + default lib is GC-built, so under `-mm=rc` everything the standard library allocates crosses a + boundary and leaks. + +**5o is done.** Not by this section - by §9.46, §9.53 and §9.54, on 2026-09-04 and 2026-09-05. +What this section adds is the evidence, and the lesson that an item's own text is a claim about +the compiler as it was, not as it is: three sessions of work went past it without re-reading it. +No code changed here. + +### 9.65 Re-reading the open claims (the audit 9.64 asked for) + +§9.64 closed 5o by discovering its text described a compiler that no longer existed, and ended +with the obvious follow-up: the other open items assert things too, and nothing re-checks them. +This is that sweep. Seven claims, each turned back into the one-line experiment that produced it. +**Five were stale. One was mischaracterised in a way that matters. One holds.** + +| claim | where | verdict | +| --- | --- | --- | +| `catch (e: int) { using r = new Res(); }` still crashes | §9.17 | **stale** - runs in all three models | +| throwing from a `finally` still crashes the compiler | §9.17 | **stale** - runs, and the throw is caught | +| a heap local in a `catch`/`finally` is not owned, and leaks under `rc` | §9.17 | **stale** - reclaims identically | +| throwing from a `[Symbol.dispose]()` "does not work at all today" | §9.62 | **false** - it works | +| ...so the terminate price is "only in principle" | §9.62 | **false** - it is real and observable | +| reading a catch variable reads 0 | §9.29 | **open, and worse than that** | +| `--di --opt_level=0` emits no LLVM IR for an `rc` program | §9.31 | **holds** | + +**The leak that was not there.** §9.17 kept `blockIsInsideCatchOrFinally` and named its second +cost: `localTakesOwnership` consults the same predicate, so a heap local declared in a catch +clause is unowned and leaks. Measured behind an interface so nothing elides it, 300k iterations: +the local declared *inside* the catch reads **4.2 MB against `none`'s 13.4**, and so does the +same allocation one scope out. There is no leak to close, because §9.30's discarded-temporary +pass consumes the call's reference whether or not MLIRGen made the local an owner. Two +mechanisms, one debt, and the later one covers the case the earlier one declines. + +**§9.62's price is real, which changes what it cost.** That section accepted "a disposal that +throws while unwinding terminates instead of continuing outwards" on the stated grounds that +throwing from a `[Symbol.dispose]()` does not work at all, so the price was theoretical. It is +not: a `using` whose disposal throws, with something to catch it, prints `body / caught / done.` +and exits 0. The failing case that produced the original claim was a throwing disposal with +*nothing* to catch it - and a plain `throw 1` with no `using` anywhere gives the identical +`0xE06D7363` and exit 127, because an uncaught exception terminates a process. + +> **One correction to this paragraph, from §9.66's work.** "`??_7type_info@@6B@` appears nowhere" +> was measured with `--shared-libs=TypeScriptRuntime.dll`, and is too strong. Without that +> library *every* throwing program fails to JIT on that symbol, throwing disposal or not - which +> is almost certainly what §9.62 hit. The correction that stands is the attribution: the symbol +> has nothing to do with disposals. So the price is now measurable, and it is paid: an inner `using` whose disposal +throws while an exception is already unwinding terminates at `0x80000003` in every model. Worth +saying plainly, because TC39's explicit-resource-management proposal specifies `SuppressedError` +there - the original error preserved, the disposal's error attached - and terminating is not that. +The C++ rule §9.62 cited is a defensible choice; it is a choice, and it was made on a premise that +was not true. + +**The catch-variable bug is JIT-only, and that is the whole diagnosis.** §9.29 recorded it as +reading 0 rather than 2, "only in a module that throws just that one type". Both halves mislead. +It is not 0 and it is not a constant: the same binary run three times reads 134, 131, 184. It is +uninitialised memory, and every payload type has it - `int`, `number` and `string` alike, the +last printing a garbage pointer's bytes. It is not about how many types the module throws either; +three `int` catches in a row read 425, -1765822016, 425. + +What it *is* about is which back end runs. Ahead of time every one of those cases is right, three +runs each: + +| | JIT (gc / rc / none) | AOT | +| --- | --- | --- | +| `try { throw 2 } catch (v: int) { t = v }` | 134, 131, 184 / 0, 0, 0 / 73, 135, 15 | **2, 2, 2** | +| a `number` catch | denormal garbage | **2.5** | +| three `int` catches | 425, -1765822016, 425 | **7, 8, 9** | + +The LLVM IR is not where it goes wrong. The catchpad names the slot and the load reads that slot, +`_CT??_R0H@84` carries `sizeOrOffset` 4 - §9.15's fix intact - and the ThrowInfo chain is +well-formed. What differs between the two runs of that same IR is the image base: every RVA in +those descriptors is a 32-bit truncation of `x - __ImageBase`, and the JIT reaches +`_CxxThrowException` through the image-base shim §9.13 built. The handler is found, so the clause +runs; the object is not copied into the slot, so the read is whatever the frame held. + +**Two consequences worth acting on.** §9.29's rule - "never write a test that reads a catch +value" - is too strong: ahead of time it is correct, and the AOT tier already runs every corpus +file, so a catch-value assertion there is a real test that nothing else provides. And the JIT +tier cannot be trusted on this at all, which is a much sharper thing to know than "it depends on +what else the module throws". Filed: + +5am. **A catch variable's value is uninitialised under the JIT.** Not an ownership bug and not + `rc`-specific - all three models, both opt levels, every payload type, and correct ahead of + time in every case tried. The suspect is the image-base-relative RVAs the MSVC EH descriptors + are built from, read under the JIT's `__ImageBase` shim (§9.13): the handler is found and the + clause runs, but the exception object never reaches the slot the catchpad names. Supersedes + §9.29's "reads 0" and its "depends on what else the module throws". + +**Two new tests, and the suite says what is broken.** `00catch_value.ts` is the coverage §9.29 +declined to write - three clauses of one type in a row, two payload types in one function, a value +read after its clause has ended - and it passes in both tiers, because six catch values is enough +to land in the working regime. `00catch_value_minimal.ts` is the same feature cut to one clause; +it passes ahead of time and fails under the JIT, so its three JIT registrations are **disabled** +rather than omitted, and ctest names them on every run. That is the convention the `BROKEN` lists +exist for: what is broken lives in the build, not only here. + +Suite 2,655 -> 2,667, of which 2,664 run and 3 are disabled and counted out loud. All green. + +**The first draft of `00catch_value.ts` asserted in its own comment that it was ahead-of-time +only, because it failed under the JIT.** It does not - it passes in both tiers, and the comment +was written from the reasoning rather than from a run. Checking it is what turned "one type in the +module" into "size decides the regime", which is the more useful statement and the one that made +the minimal file worth writing separately. **A comment claiming a measurement is a measurement**, +and this section is entirely about what happens when nobody re-reads one. + +### 9.66 Item 5am: a type descriptor at RVA 0 is a `catch(...)` (and 9.65's suspect was innocent) + +§9.65 found the catch-variable bug to be JIT-only and filed 5am naming the image-base shim of +§9.13 as the suspect. **That suspect is innocent, and the real cause is one line of arithmetic.** + +**What the personality is actually handed.** Wrapping `__CxxFrameHandler3` under the JIT and +printing its inputs settles the throw side immediately: magic `0x19930520`, four parameters, and + +``` +[eh] jitImageBase=000002842c3d0000 dcImageBase=000002842c3d0000 (match) +[eh] EstablisherFrame=000000b08c78e740 thrown value=2 at ...e76c +``` + +The two image bases **match**, so the shim works. The addresses reconcile with the disassembly +exactly - the thrown object at frame+0x2c is `rbp-0x14`, the catch slot at frame+0x3c is +`rbp-0x4` - and the slot reads the same garbage before the search call and again at the +consolidate, so the copy simply never happens. + +**Resolving the tables the way the handler does gives the answer in one line.** Walking +`HandlerData -> FuncInfo -> TryBlockMap -> HandlerType` against `pDC->ImageBase`: + +``` +handler[0] adj=00000001 dispType=0 dispCatchObj=60 dispOfHandler=327952 dispFrame=56 +bytes at ImageBase+16: '.H' +``` + +`dispCatchObj` (0x3c) and `dispFrame` (0x38) are right. **`dispType` is 0** - and in the MSVC +encoding a zero type RVA means `catch(...)`. The clause is silently a catch-all. It still +catches, which is why nothing looks wrong; but a catch-all has no catch object, so +`BuildCatchObject` copies nothing and the clause reads whatever the frame held. + +And `dispType` is 0 because it is **correct**. `'.H'` at the image base is `??_R0H@8`'s own name +field: the `int` type descriptor is sitting *at* the image base, so its image-relative offset +really is zero. RTDyld defines the image base as the lowest section load address, so whatever +datum lands lowest gets RVA 0 - and RVA 0 is the encoding's sentinel for "no type". Ahead of +time this cannot happen: RVA 0 of a PE is the DOS header, and no datum is ever there. + +**Everything §9.29 and §9.65 saw follows from that, including the parts that looked contradictory.** + +| observation | why | +| --- | --- | +| garbage, different every run | an uninitialised frame slot, never written | +| "only in a module that throws just that one type" (§9.29) | which descriptor lands lowest depends on what the module contains | +| "size decides the regime" (§9.65) | more content, and the descriptor is no longer at the lowest address | +| an `int` clause still declines a `string` throw | that program's `char*` descriptor is at the base; the `int` one has a real RVA and filters correctly | +| `00catch_value.ts` passes, `00catch_value_minimal.ts` does not | measured: the passing file's handlers read `dispType=65536, 65584, 65632`, and nothing meaningful sits at its image base | + +That last row is a prediction made before it was run, which is what makes it evidence rather than +a story: the file was known to pass, so its descriptors had to be off the base, and they are. + +**The fix is not in the shim, and not a one-liner.** The datum at RVA 0 is always the first byte +of the lowest section, so "nothing at RVA 0" cannot be arranged by padding an allocation - the +base moves with it. What can be arranged is *which* section is lowest: no field in the MSVC EH +encoding treats a **code** RVA of 0 as a sentinel, so a layout that puts code below all read-only +data removes the ambiguity. `SectionMemoryManager` supports exactly that through +`needsToReserveAllocationSpace`/`reserveAllocationSpace` - reserve one region and lay out code, +then read-only, then read-write. The alternative is JITLink's `ObjectLinkingLayer`, which models +an image base explicitly, and which is a much larger change. + +5am is therefore re-stated rather than closed, with the diagnosis it was missing: + +5am. **A catch clause whose type descriptor lands at the JIT image base becomes `catch(...)`.** + RTDyld's image base is the lowest section load address, so a descriptor allocated there has + RVA 0, and `dispType == 0` is the MSVC encoding's `catch(...)`. The clause still catches, so + only the missing catch-object copy is visible - the value reads as uninitialised memory. + Not `rc`-specific, not an ownership bug, and correct ahead of time in every case tried. The + fix is to place code below read-only data via `reserveAllocationSpace`, so that the RVA-0 + sentinel can only ever fall on code, where nothing reads 0 as "none". Proven in §9.66; the + image-base shim of §9.13, which the item previously accused, is measured working. + +### 9.67 `+` does not promote its right operand (5an) + +Found while writing §9.65's catch-value tests, where `t + u` over a caught `int` and a caught +`number` read 5 rather than 5.5. It was kept out of that file - a test about catch values should +not also be a test about arithmetic - and is filed here rather than left in a comment. + +**`+` takes the type of its left operand and coerces the right one to it.** Every other +arithmetic operator promotes correctly: + +| expression | reads | should be | +| --- | --- | --- | +| `i + f` | **5** | 5.5 | +| `f + i` | 5.5 | 5.5 | +| `2 + 3.5` | **5** | 5.5 | +| `i * f` | 7 | 7 | +| `f - i` | 1.5 | 1.5 | +| `i / f` | 0.571429 | 0.571429 | + +with `i = 2`, `f = 3.5`. Both tiers, all three memory models, both optimisation levels, and +literals are not spared - `2 + 3.5` is wrong on its own. + +**It is the right operand that is converted, not the result.** `1 + (-0.5)` reads `1`, which is +`1 + trunc(-0.5)`; truncating the sum would give `0`. So the addition happens in integer, after +discarding the fraction, rather than in double and then narrowing. + +`+` is the one arithmetic operator that is also string concatenation, so it is the one with a +result type chosen ahead of the operands rather than from them; that is the obvious place to +look. Nothing here is reference counting, and nothing in the corpus caught it, which is its own +result: 2,664 tests and none of them adds an integer variable to a float one. + +5an. **`+` coerces its right operand to the left operand's type instead of promoting both.** + Not an ownership bug and not `rc`-specific - every model, both tiers, both optimisation + levels, and constant operands too (`2 + 3.5` reads 5). `-`, `*` and `/` are all correct, which + points at `+` being overloaded for string concatenation and so choosing its result type before + looking at both operands. See §9.67. + +### 9.68 5am fixed, and the fix was one flag + +§9.66 proved the cause and then estimated the fix as taking section allocation over from +`SectionMemoryManager`, W^X handling included, and deferred it on that basis. **That estimate was +wrong, and it was made without reading the header.** LLVM already implements exactly the layout +required, for the ARM ABI, behind a constructor argument that defaults to false: + +```cpp +JitSectionMemoryManager() : llvm::SectionMemoryManager(nullptr, /*ReserveAlloc=*/true) {} +``` + +`reserveAllocationSpace` takes one contiguous block and fills it code, then read-only, then +read-write. Code is therefore always the lowest section, so the RVA-0 collision can only ever +land on code - and no field in the MSVC EH encoding reads a code RVA of 0 as "none", where +`dispType == 0` on a *data* RVA meant `catch(...)`. + +| shape | before | after | +| --- | --- | --- | +| `catch (v: int)`, three runs of one binary | 134, 131, 184 | **2, 2, 2** | +| `catch (v: number)` | denormal garbage | **2.5** | +| three `int` catches in a row | 425, -1765822016, 425 | **7, 8, 9** | +| two payload types in one function | `t=0 u=3.5` | **`t=2 u=3.5`** | +| an `int` clause declining a `string` throw | correct | correct | + +The three JIT registrations of `00catch_value_minimal.ts` are re-enabled and both +`TSLANG_CORPUS_BROKEN_JIT_*` lists are empty again. Suite **2,667/2,667 with nothing disabled**, +up from 2,664 run with three disabled. + +**The price the flag names.** All memory is pre-allocated from the sizes RTDyld computes up +front, and an allocation beyond them fails rather than growing. That is the trade the ARM users +of this path already make, and the corpus - 2,667 tests, every one of them JIT or AOT across +three memory models - exercises it without a failure. It is worth knowing about if a future +module is much larger than anything here. + +**The lesson is the same one this document keeps producing, one level up.** §9.64 found an item +whose text described a compiler that had moved; this is an item whose *fix estimate* described a +library that already did the work. The estimate cost a session of deferral. Read the header +before costing the change. + +### 9.69 Debug info under `-mm=rc` (item from §9.31, open since 2026-09-04) + +§9.31 recorded it in one line - "`--di --opt_level=0` fails to emit LLVM IR for any +reference-counted program" - and left it. §9.65's audit confirmed it was the one open claim of +seven that still held. It is fixed, and it was a standing tax the whole time: every RC +investigation in this document was carried out in release builds because a debug build of an +`rc` program did not exist. + +**The cause.** Every op in a generated ownership routine is built with `op->getLoc()`, because +that is the only location in scope - the routine is synthesised from a type, not from source. +Under `--di` that location is the *enclosing user function's*, complete with its `DISubprogram`, +and the translation attaches it to whatever function it lands on. So `main`'s subprogram was +attached to `tsrel_...`, `tsret_...`, `__tslang_inc_ref`, `__tslang_dec_ref` and +`__tslang_free_block` as well, and one `DISubprogram` cannot belong to two functions. The +routines are `rc`-only, which is exactly why `gc` and `none` never saw it. + +**Four attempts, and each failure named the next one.** Worth keeping in that order, because the +last two are not things one would predict: + +| attempt | what it hit | +| --- | --- | +| drop the locations entirely | `DIScopeForLLVMFuncOpPass` then *gives* each routine a subprogram of its own, and its now-unlocated body trips the opposite check - "inlinable function call in a function with a DISubprogram location must have a debug location" | +| peel off the `DISubprogram` only | an op fused with a `DILocalVariable` still names a scope inside the user function: "!dbg attachment points at wrong subprogram" | +| reduce to plain file/line | 7 of 15 files build, the rest die on a `phi` - **block arguments carry locations, and an operation walk never visits them** | +| unwrap `NameLoc` too | 453 of 453 - `loc("sAny"(fused<#di_subprogram
>[...]))` hides the scope one layer further in than `FusedLoc` and `CallSiteLoc` | + +So the routine keeps a real location and borrows no scope: reduced through `FusedLoc`, +`CallSiteLoc` and `NameLoc` to the file and line underneath, across block arguments as well as +ops. It reaches the scope pass with no subprogram, is given one of its own, and every op and +argument in it still has somewhere to hang. `gc` and `none` are untouched, because these +routines do not exist there. + +**Every corpus file that compiles alone now emits IR under `-mm=rc --di`: 453 of 453.** "Up +from none" would overstate it, and the exception is worth knowing: a program that allocates +nothing generates no ownership routine and so had nothing to collide - `print("literal")` built +fine before this, where `let x = 1; print(x)` did not, because printing a number allocates. That +is the whole of what worked. Suite 2,668/2,668. `00owned_debug_info.ts` is registered as `test-compile-rc-debug-info` +and is the only test in the suite that passes `--di` with `-mm=rc`, which is why the gap lasted +as long as it did - nothing ran the combination. + +### 9.70 `+` promotes both operands (5an), and it was not only floats + +Fixed. `adjustTypesForBinaryOp`'s `PlusToken` case took the left operand's type and cast the +right one to it. Every other arithmetic operator shares a promotion path that picks the *wider* +of the two - a widest-first list, `syncTypes` casting both sides to the first type either of +them already has - and `+` was the one operator not using it, because it is also string +concatenation and so has to keep `x + 1` as concat when `x` is a string. + +The one-line shape of it, on `let i = 2; let f = 3.5; i + f`: + +```mlir +%9 = "ts.Cast"(%8) : (!ts.number) -> si32 // 3.5 truncated to 3, before the add +%10 = "ts.ArithmeticBinary"(%7, %9) : (si32, si32) -> si32 +``` + +`+` now uses the shared promotion whenever **both** operands are unambiguously numeric, and +keeps the old left-preferring behaviour for every other shape - strings, `any`, unions, objects +with `[Symbol.toPrimitive]`, `undefined`, `null`. That boundary is the whole of the care needed +here: `any` is not promotable, so `let x: any = "a"; x + 1` still takes the path it took before. + +**Three observable defects, not one.** Section 9.67 recorded the int-plus-float case. Measuring a +control binary - the compiler rebuilt with the fix stashed - found the same truncation in two +more shapes it had not looked at, and both are silent wrong answers rather than crashes: + +| expression | before | after | +| --- | --- | --- | +| `i + f`, `2 + 3.5` | 5 | 5.5 | +| `1 + (-0.5)` | 1 | 0.5 | +| `true + 2.5` | **2** | 3.5 | +| `i8(100) + i32(1000)` | **76** | 1100 | + +76 is 1100 truncated into an `i8`. So this was never about floats: it was `+` narrowing its +right operand to the left one's type in *any* direction, and a float fraction was simply the +easiest way to notice. `f + i`, `2.5 + true` and `i32 + i8` were all correct already, because +there the left operand was the wider one - which is exactly why a test written from one +direction only would have passed. + +The control also settled a case that looked like a regression and was not: `any + number` fails +to compile ("Binary operation is not supported for type: '!llvm.ptr'"), before this change and +after it, identically. + +`00add_promotes_both_operands.ts` covers all of it, in the corpus so it runs under all three +memory models and both optimisation levels. Five of its cases fail on the control and the rest +are guards, which is the composition to want - the guards are the string-concatenation cases the +left-preferring rule exists for. + +### 9.71 An imported function's result is taken over (5al) + +Fixed, and it was worth much more than the item said. + +5al was filed as a classification refusal with a soundness question attached: +`functionReturnsOwned` reads a function's returns to decide whether it hands back a reference, +an imported function has no body here to read, so every one of them was refused. The item then +stalled on whether consuming such a result is sound at all, since a statically linked module +carries no memory-model marker. + +**First, the size of it, which nobody had measured.** A two-module program whose work is an +imported method returning a string, 300k calls, AOT, `--opt --opt_level=3`: + +| | before | after | +| --- | --- | --- | +| `rc` | **18.0 MB** | **3.8 MB** | +| `gc` | 5.7 | 5.4 | +| `none` | 18.0 | 17.6 | + +`rc` and `none` agreed *to the decimal* before this. Across a module boundary, reference +counting was reclaiming nothing whatsoever - not "less than it could", nothing - and it now +reclaims more than GC does on the same program. That is a different item from the one filed, +which read as a precision refinement worth a couple of calls. + +It is also not confined to imported functions. The candidate set for a virtual call is every +method in the module sharing the member name, so a single bodyless imported `M.Animal.speak` +made `Dog.speak()` unclassifiable too, on a class defined entirely in this module. + +**Second, the soundness, which turned out to be already provided.** Two things have to hold. + +That the callee is a tslang function rather than a foreign one is easy and was always easy: an +imported tslang declaration carries `export`, re-printed from the exporting module's source; a +`declare`d C function returning a `string` carries nothing and must never be consumed. + +That the defining module returns +1 (section 9.24) is the part the item stalled on. Two +measurements answer it. Sweeping the corpus with a counter on the classifier: of the functions +that return a heap value, **36 of 36 exported ones with a body classify as returning owned**, +and the only 16 that fail are `.next` methods of generator state objects - anonymous, internal, +and not exportable. And for the mixed link the item was actually worried about, consumption +*removes a receiver's retain* rather than adding a release, so a foreign block's count goes to +-1, which is `HEAP_BLOCK_IMMORTAL`, and it leaks instead of being freed. That is section 4's +agreed policy, and the born-at-zero design of section 9.24 was already delivering it. + +All nine exporter/importer model combinations were run to check that, and all nine produce the +right answer and exit 0. The two mixed rows that matter hold their memory - `none` into `rc` at +18.0 MB - rather than crashing. + +**A change I made and then took back out.** Believing the mixed link needed protecting, I had +`_MemoryAlloc` write `HEAP_BLOCK_IMMORTAL` under `gc` and `none` instead of leaving the header +word as `malloc` found it, so that a counting importer could never read an uninitialised count. +The argument is still sound on paper, and the code carries a deliberate decision the other way +("a store per allocation on the hot path is not worth paying for dead code"). But across eleven +measured configurations - the nine-way matrix plus two tests built to detect a premature free, +one of them deliberately filling the allocator's free lists with the value 1 first - **it made +no observable difference to anything**, so it was reverted rather than shipped on reasoning. +Reversing a deliberate hot-path decision needs a failing case, and three attempts did not +produce one. + +**Coverage.** `import_owned_returns.ts` / `export_owned_returns.ts`: an imported method, a local +override of one, virtual dispatch to that override, a method through an imported interface, and +a plain imported function - results held, allocated over, then read back, because a freed block +keeps its contents until something reuses it. Registered under all three models both statically +and shared. The teeth are not hypothetical: this test's assertion fires for real, on 5ao below. + +**Two things found on the way, both pre-existing.** + +5ao. **A shared library built `gc` and linked ahead of time frees strings the importing module + still holds.** `test-compile-shared-export-import-owned-returns`, registered and DISABLED. + It fails with 5al's fix reverted as well, so it is not that fix's doing, and every + neighbouring configuration passes: the same test shared under `rc` and under `none`, + statically linked under `gc`, and shared under `gc` through the JIT. Only shared plus `gc` + plus AOT. That shape points at Boehm not tracing the importing module's roots into a + dynamically linked module's heap. DISABLED rather than WILL_FAIL because it produces + corrupted memory rather than a clean failure. + +- **The plain multi-file test path had no working-directory isolation.** Object files are named + after the source stems, so two tests built from the same pair of sources - the `gc`, `rc` and + `none` variants of one import/export pair - delete each other's `.obj` under `ctest -j`. This + stayed hidden only because every such pair had been registered exactly once, which is also why + **the statically linked two-module form had no `rc` or `none` coverage at all** - the exact + configuration whose leak went unmeasured until now. `createMultiCompileBatchFile` now creates + the same per-test directory the shared path has created for this reason all along. It showed + up as a single failure in a full parallel run that passed when run alone; four consecutive + full runs are clean since. + +**Suite 2,680 of 2,680**, one disabled (5ao). Cross-module `rc` 4.1 MB against `gc`'s 5.7 and +`none`'s 18.0. + +**One number that looked stale and was not.** Section 9.60 records `raytrace` at 6.2 MB; it +measures 8.9 today. Chased in section 9.72 - it is the measuring harness that changed, not the +compiler, and neither fix here moves `raytrace` at all. + +### 9.72 The harness changed, not the compiler - and the old one halved `raytrace` + +Section 9.71 ended by flagging section 9.60's `raytrace` figure of 6.2 MB as stale, on the +strength of measuring 8.9 today, and suggested something between the two had moved it. That was +worth checking rather than filing, and checking it says the suggestion was wrong. + +**Checked at the commit that wrote the number.** Building `3d8bddf6` - the commit whose diff +introduces the string "6.2 MB" - and measuring `raytrace` there with the current script gives +**9.2 MB**, not 6.2. There is no regression to find between then and now, because the number was +never 6.2 on this harness at that commit either. + +**What differs is how the peak is read.** Section 9.52 describes two generations of harness: an +original that spun on `PeakWorkingSet64` while the process ran, and the present +`scripts/measure_memory_model.ps1`, which does not sample at all - it calls +`GetProcessMemoryInfo` after `WaitForExit`, since the kernel keeps the peak for as long as a +handle stays open. That script was added in `299eed65`, *after* section 9.61. Every memory number +in sections 9.52 to 9.61 was therefore taken by sampling. + +Running one already-built `raytrace-rc.exe` both ways, six times: + +| run | sampled | after exit | +| --- | --- | --- | +| 1 | 4.9 | 8.9 | +| 2 | 4.1 | 8.9 | +| 3 | 4.0 | 8.9 | +| 4 | 4.0 | 8.9 | +| 5 | 4.1 | 8.9 | +| 6 | 4.2 | 8.9 | + +The sampling technique reports **less than half** the real peak, and wanders by 20% between runs +of the same binary; reading the counter after exit gives the same figure every time. Section +9.52 predicted exactly this failure - "a program that finishes before the *first* sample defeats +the no-sleep rule as thoroughly as sleeping does" - and then the sections after it went on +quoting sampled numbers, because the replacement script did not arrive until `299eed65`. + +**What this invalidates.** Not the conclusions - `raytrace` really did fall from tens of MB to +single digits, and every one of those fixes was a real fix. What it invalidates is *comparing a +number from sections 9.52-9.61 against one measured since*, and the precise magnitudes in that +range, which are understated by something like the factor above. Anything quoted from there +should be re-measured before it is used as a baseline rather than trusted to the decimal. The +in-tree script has been the only harness since `299eed65`, so numbers from section 9.62 onwards +are on the stable footing. + +**A current baseline, so the next section has something sound to compare against.** All taken +with `scripts/measure_memory_model.ps1` at `c3f629e4`, AOT, `--opt --opt_level=3 +--no-default-lib`: + +| program | gc | rc | none | +| --- | --- | --- | --- | +| `raytrace.ts` | 5.8 | **8.9** | 114.5 | +| `nbody.ts` | 5.6 | **4.1** | 4.1 | +| cross-module, 300k imported calls (§9.71) | 5.7 | **4.1** | 18.0 | + +`raytrace` reclaims 92% of what `none` leaks and remains the one program where `rc` is behind +`gc` rather than ahead of it - which is the honest headline, and a less flattering one than the +6.2 that was being quoted. **Superseded within the hour: §9.73 found what that gap was, and +`raytrace` is now 4.1 MB, flat at every image size and below `gc`.** `nbody` allocates almost nothing per step, so `rc` and `none` agree +and `gc` pays for its runtime. Quote these rather than anything from §9.52-§9.61. + +**And the 0.3 MB that section 9.71 attributed to this work is not real either.** Measuring +`20dfb37f` - the commit before 5an and 5al - gives **8.9 MB**, the same figure as HEAD with both +fixes applied, stable across four runs each. The 9.2 readings were first-run noise on a +freshly built binary. Both fixes move `raytrace` by nothing measurable, which is what one would +expect: it is a single-module program, so it imports nothing, and its arithmetic is `number` +throughout, so nothing in it was being narrowed. + +The lesson worth keeping is smaller than the finding: **a number and the tool that produced it +travel together.** Three sections' worth of memory figures went into this document without +recording which harness took them, and the one line that would have prevented an hour of +bisecting is the harness name beside the number. + +### 9.73 The branches of a conditional disagreed about ownership (5ap) + +`raytrace` under `rc` is **3.8 MB at every image size**, flat, against `gc`'s 5.5 and `none`'s +10.8 to 445.0. It was 81 bytes per pixel above that floor an hour ago, and the whole of it was +one line of TypeScript. + +**Finding it started from the corrected baseline.** §9.72 left `raytrace` as the one program +where `rc` (8.9 MB) was behind `gc` (5.8). Rendering at four sizes says what kind of cost that +is - a fixed floor, or something per pixel: + +| pixels | rc | none | +| --- | --- | --- | +| 64x64 | 4.5 | 11.1 | +| 128x128 | 5.4 | 31.8 | +| 256x256 | 9.2 | 114.5 | +| 512x512 | 24.5 | 445.3 | + +A two-point fit on the extremes predicts the two middle rows to within 0.06 MB in both columns: +`rc` = 4.18 MB + **81.3 bytes/pixel**, `none` = 4.21 MB + 1764 bytes/pixel. So a floor both +share, and a real per-pixel retention of 4.6% of everything allocated. §9.59 had found a *flat +37%* by the same method, so that leak was gone; this was a smaller, different one. + +**Four hypotheses died before the right one.** Worth recording, because each was plausible and +each cost only one measurement: + +| hypothesis | test | result | +| --- | --- | --- | +| the two arrows built per pixel inside `getPoint` | hoist them, then delete them | 24.2 to 24.5 - nothing, and `none` does not move either, so at `-O3` they never allocated | +| `Intersection` object literals through an interface (§9.59's own suspect) | make `Intersection` a class - **the measurement §9.59 said could not be taken until 5aj was fixed** | 24.2 to 24.6 - nothing | +| the `{ start, dir }` ray literal in the reflection path | hoist it into a local | 24.6 - nothing | +| `traceRay` returning the borrowed global `Color.background` | return a fresh `Color` instead | 24.6 - nothing | + +Splitting `shade` into its two halves is what localised it. With the 4.1 MB floor subtracted: + +| variant | rc excess | none excess | reclaimed | +| --- | --- | --- | --- | +| baseline | 20.1 | 440.9 | 95.4% | +| no natural colour, reflection kept | 18.2 | 149.0 | 87.8% | +| **no reflection, natural colour kept** | **1.2** | 243.9 | **99.5%** | + +All of it was in the reflection path, and stepping `maxDepth` 0,1,2,3,5 showed the leak appear +the moment reflection is enabled at all and then decay geometrically like the work itself - a +fixed *share* of that path, not something accumulating with depth. What distinguishes that path +is one line in `shade`: + +```typescript +let reflectedColor = (depth >= this.maxDepth) ? Color.grey : this.getReflectionColor(...); +``` + +Rewriting **just that ternary** as an `if`/`else` with an assignment: `rc` 24.6 to **6.4 MB**, +`none` unchanged at 445.3. + +**The bug.** A conditional expression builds a `ts.If` with a result, and its branches hand back +references on different terms. Reduced to 14 lines, `cond ? aGlobal : make(n)` in a loop: + +| | rc | none | +| --- | --- | --- | +| ternary | **56.9** | 56.9 | +| the same thing as `if`/`else` | **4.1** | 56.9 | + +Identical allocation, and reference counting reclaimed *nothing at all* through the ternary. The +IR says why in one attribute: + +```mlir +// ternary // if / else +%9 = "ts.CallIndirect"(...) {__owned_result} %12 = "ts.CallIndirect"(...) {__owned_result, + __owned_result_consumed} +"ts.Result"(%9) "ts.ReleaseSlot"(%5) +... "ts.Store"(%12, %5) +%5 = "ts.Variable"(%4) {__owned} +"ts.RetainSlot"(%5) // a SECOND owner +``` + +The call's +1 escapes the region through `ts.Result` and nobody takes it over, while the +receiver outside retains as well: two owners, one release. §9.30 cannot reach it and says so in +its own terms - the value's only user is a terminator, and releasing a value handed to a +successor would free it while it is still live. + +**The fix makes the branches agree before anything outside looks.** Whichever branch borrows +takes a reference of its own, so that every branch yields +1; the `ts.If` result then carries a +reference exactly as a call's result does, and the ordinary machinery applies - a receiver takes +it over, or §9.30 gives it back at the end of the block. `consumeConditionalResults` in +`OwnedReturnConsumptionPass`. + +**Retaining the borrowed side, rather than releasing the owned one, is the whole of the care +here.** There is no point inside the region at which the owned value could safely be released: +the receiver's retain has not happened yet, so releasing there frees a live value. That is not +a theoretical preference - building exactly that mistake (mark the result owned, consume the +receiver's retain, skip the retain on the borrowing branch) makes +`00conditional_owned_result.ts` fail at both optimisation levels with "a conditional's result +was freed while still referenced". The test has teeth in the dangerous direction, which is the +only direction that matters, and it holds results from both branches and allocates over them +before reading them back, because a freed block keeps its contents until something reuses it. + +**Result.** `raytrace` is flat at 3.8 MB from 64x64 to 512x512 - the per-pixel retention is not +reduced but *gone* - and `rc` is now below `gc` at every size. Suite 2,684 of 2,684, one +disabled (5ao). The ownership verifier is clean across all 496 corpus files. + +| program | gc | rc | none | +| --- | --- | --- | --- | +| `raytrace.ts` (256x256) | 5.8 | **4.1** | 114.5 | +| `nbody.ts` | 5.6 | **4.1** | 4.1 | +| cross-module, 300k imported calls (§9.71) | 5.7 | **4.1** | 18.0 | + +Every one of those is at the allocator's floor. Supersedes the table in §9.72, which was +measured before this. + +5ap. **DONE, §9.73 - a conditional expression whose branches disagree about ownership.** The + allocating branch's +1 escaped through `ts.Result` unclaimed while the receiver retained + separately. `cond ? borrowed : f()` is not a corner of the language, and one such line held + 18 of `raytrace`'s remaining 20 MB. + +**What this says about the method.** §9.59 named three suspects for `raytrace`'s residue and the +real cause was none of them - it was not any *kind of value*, it was a *control-flow shape* that +no amount of staring at allocation sites would have suggested. What found it was bisecting the +program by deleting halves of the work and watching which half took the leak with it. Four +one-measurement hypotheses cost less than any one of them would have cost to reason about, and +the §9.59 suspect that had been waiting on 5aj since it was filed turned out, once finally +measurable, to be innocent. + +### 9.74 What `splice` deletes is given back (5f's remainder), and two bugs beside it + +`splice` is the last leaking insertion point, open since §9.22 filed it and left it deliberately: +what it removes is memmoved over and the array realloc'd, so the references in those slots were +overwritten rather than released. Measured before the fix, a loop splicing two of three boxed +strings away held **16.4 MB against 4.1 MB** for the same program without the splice, `none` at +28.7 so nothing was elided. It is **3.8 either way** now. + +**Why this one is in the lowering.** Every other insertion point in this arc sits in MLIRGen, +where how many elements are involved is a compile-time matter. Here the count is a runtime +value that only exists after conversion, so this is the first - and so far only - release +emitted from `LowerToLLVM`. `OwnershipRoutineLogic::emitReleaseArrayElements` walks the deleted +range with the existing counted-loop helper and calls the element's release routine, which +already existed for the case where a whole array is released. + +Two things had to be checked before emitting it, and both could have made it an over-release: + +- **The release routines exist in every memory model.** They are reference-counting shaped + everywhere and dead weight under `gc` (§9.4); what keeps them dead is that `ts.Release` erases + on the way to LLVM (§9.10). A call planted directly by a lowering has no such eraser in front + of it, so it has to test `isRefCounted()` itself, or `gc` would start freeing objects it is + still tracing. +- **`splice` must not hand the removed elements back to anyone.** JavaScript's returns them as + an array; if tslang's did, releasing them would free values the caller still held. It returns + a **count** - `["aa","bb","cc","dd"].splice(1, 2)` prints `2` - so nothing outside can reach + them. Checked before the release was written rather than assumed. + +The release runs on the original data pointer and before the grow/shrink branch, which is the +only correct placement: the growing branch reallocs *first*, and a realloc may move the block. + +**A pre-existing crash, found by the test and fixed here because it had to be.** `deleteCount` +was never clamped to what the array holds, so `["p","q"].splice(1, 10)` computed `2 - 1 - 10` in +an unsigned index and asked `memmove` for about 2^64 bytes: + +``` +Exception Code: 0xC0000005 ... GC_realloc +``` + +Every memory model, and nothing to do with reference counting - but it had to be settled first, +because a release loop walking off the end reads freed memory rather than merely computing a +wrong size. Clamping `deleteCount` to `length - start` fixes both and is what JavaScript +specifies anyway. + +5aq. **DONE, below - pushing one owned value into two arrays consumed it twice.** + +**The second bug, and the more serious one.** The new test failed its own over-release +assertion, and the cause was not `splice` at all: + +```typescript +const keep = new Box("kept"); +victim.push(keep); // consumes the +1 +survivors.push(keep); // must retain - and did not +``` + +A `const` initialised from `new` gets no storage of its own, so both pushes see the *same* +`ts.CallIndirect` and `retainInsertedElements` asked only whether it carried `OWNED_RESULT`, +never whether that reference had already been taken. Both consumed it. One reference, two +holders, and whichever array died first freed an element the other still held. **It reproduces +with no splice anywhere in the program** - the surviving array reads back the churn value - so +it long predates this section; what made it visible is that until now an array outliving its +elements never gave them back, so the second holder was never exercised. One condition: +consume only when the reference has not already been consumed, otherwise retain. + +That is the failure mode this arc has treated as the one that matters, and it took a fix in a +neighbouring area to expose it. Worth remembering next to §9.25's note that this was already +"the one receiving site that skipped its retain without recording the consumption" - it was +also skipping the *check*. + +**Coverage.** `00owned_array_splice.ts`: an element spliced out of one array while another still +holds it (both objects and strings), splice that inserts as well as removes, an over-long delete +count, a zero delete count, and a non-owning element type. Its teeth are in the over-release +direction and are not hypothetical - it fails on the compiler as it stood, twice over: the +double-push assertion, and the over-long delete count crashing outright in all three models. + +Suite **2,688 of 2,688**, one disabled (5ao). Ownership verifier clean across all 497 corpus +files. + +### 9.75 Cycles, written down for users + +The other half of "what is left" was never code. §4 settled the policy - leak cycles, document +it, keep `gc` the default - and nothing in the repository told a user any of it. `-mm=` appeared +in no user-facing document at all. + +`docs/memory-models.md` now covers the three models, what `-mm=rc` buys, and cycles: the shapes +that leak, the shapes that do not, and what to do. Every claim in it is measured rather than +reasoned: + +| shape | rc | none | +| --- | --- | --- | +| `a.parent = b; b.parent = a` | 22.6 | 22.6 | +| doubly linked (`next`/`prev`) | 22.6 | 22.6 | +| an object holding a closure that captures it | 22.6 | 22.6 | +| the same loop with the back-reference removed | **4.1** | - | + +`rc` equals `none` to the decimal in all three: reference counting reclaims **none** of a cycle. +Removing one assignment takes the same loop to the floor. + +Two claims on the "does not leak" side were checked rather than assumed, and one of them +corrected a statement in §4. **A self-recursive function is not a cycle** - a named recursive +function holds no reference to itself at run time, and §4's "recursive closures are a +compiler-generated cycle" does not currently apply, because a self-referential *arrow* function +does not compile at all: + +``` +error: can't resolve name: fact + const fact = (k: number): number => k <= 1 ? 1 : k * fact(k - 1); +``` + +That matters for the shipping decision more than it looks. §4's argument for needing weak +references leaned on the compiler emitting cycles behind the user's back; it does not. Cycles +under `-mm=rc` are only what a user writes deliberately, which is exactly the position Swift +ships ARC in - and here `gc` is still the default and one flag away. + +The other check: **strings cannot participate in a cycle at all**, since a string never points +at another heap object. That is the property that makes §2's "Tier C, strings only" scope +free of this entire question. + +`WeakRef` therefore stays unimplemented and unblocking. §9.8 settled its ABI so that +`strong` sits at `payload - wordSize` in every model and `weak` exists only under `-mm=rc`, so +it can land later without a break. + +### 9.76 Two collectors in one process (5ao) + +Fixed. The suite is **2,689 of 2,689 with nothing disabled**, which it has not been at any point +in this arc. + +5ao was filed by §9.71 as "a `gc` shared library linked ahead of time frees strings the importing +module still holds", with the shape of the evidence pointing at Boehm and the cause unknown. It +is simpler and worse than that. + +**The executable and the shared library each link `gc.lib` statically, so each has its own +collector** - its own heap, its own roots. The library allocates the strings; the executable +holds them in an array the library's collector has no reason to scan, and frees them. + +Proving it took three measurements, and the first two said the opposite of the answer: + +| test | result | +| --- | --- | +| a DLL that allocates a string the exe holds, 200k churn | **passes** | +| each of the five call shapes in the failing test, alone, 100k churn | **all five pass** | +| the five together | fails at four | + +That looked like a combination effect and was not. Dumping the held values rather than counting +mismatches is what turned it round: + +``` +0 [0] Generic makes a noise. | [1] Mitzie barks. | [2] Mitzie barks. | [3] Generic makes a noise. +... +11 [0] Generic makes a noise. | [1] Mitzie barks. | [2] Mitzie barks. | [3] animal Generic +``` + +Slot 3 is `asIface.describe()`, which should read `animal Generic` every time. It reads what the +*churn loop* allocates - `a.speak()`'s result - in every entry but the last. The strings were +freed and their memory reused. + +**So every entry was being freed, and only one was detectable.** The earlier tests churned with +the *same* call they were holding, so a freed slot was reallocated with identical content and +read back correct. They did not pass; they could not fail. The bug was invisible for exactly the +reason it is dangerous, and this is the third time in this arc that a test which "passed" was +measuring nothing (§9.21, §9.22 - and here the flaw was that the churn and the held value came +from the same producer). + +Confirmed independently before any fix: with `GC_INITIAL_HEAP_SIZE=536870912`, so that Boehm +never needs to collect, slot 3 reads correctly. A collection issue, not dispatch. + +**The fix is one collector.** Boehm built with `BUILD_SHARED_LIBS=ON` - it was explicitly `OFF` - +and both binaries linked against the import library, with `gc.dll` beside the executable. Slot 3 +then reads `animal Generic` on every iteration, and the disabled test passes. + +Scoped to shared builds only, which is where the problem is: a statically linked program has one +binary and therefore already one collector, and keeps the static `gc.lib`. `scripts/build_gc_release_shared_vs.bat` +builds it; `test-runner`'s shared path links it and copies the DLL into the per-test working +directory; the test CMakeLists reports it clearly if it has not been built rather than linking +the wrong thing silently. + +**The cost, which is real and worth stating: a program that loads a tslang shared library now +ships `gc.dll`.** There is no way round it - two static collectors in one process cannot be made +correct - but it is a change to how such programs are deployed, and it is written down in +`docs/memory-models.md` rather than left in this file. + +**What this says about the rest of the shared tests.** They pass, and they were never exercising +this: they are small enough that no collection happens at all. Nothing in the suite churned +across a shared boundary until §9.71's test did. That is worth remembering when the next +shared-library feature is called covered. + +5ao. **DONE, §9.76** - two statically linked collectors, one per binary. Not an ownership bug and + not reference counting's: `rc` and `none` were always correct here, because neither has a + collector to get this wrong. + +### 9.77 A default library per memory model + +The largest instance of §9.7's cross-model leak is closed. It was never a subtle one: **every +program that does not pass `--no-default-lib` linked a garbage-collected standard library**, so +under `-mm=rc` everything the standard library allocated crossed a model boundary and was never +reclaimed. The whole corpus runs with `--no-default-lib`, which is why the arc got this far +without tripping over it. + +Measured on 2 million string concatenations through the default library: + +| | before | after | +| --- | --- | --- | +| `-mm=gc` | 5.7 MB | 5.7 MB | +| `-mm=rc` | *(a gc library, leaking)* | **4.1 MB** | +| `-mm=none` | 218.8 MB | 218.8 MB | + +`rc` is at the allocator's floor and below `gc`, on a program made entirely of standard-library +allocation. `none` is the largest column, so nothing was elided. + +**The library is not model-neutral, which is why one build could never have served.** Under `gc` +it allocates through Boehm and pulls `libgc` in with it; under `rc` it initialises the block +header's reference count and follows the +1 return convention (§9.24); under `none` it does +neither. The difference is visible in the artifacts: the `gc` build of +`TypeScriptDefaultLib.lib` carries `GC_malloc` references and the `rc` and `none` builds carry +none, and a hello-world linked against them comes out 335 KB under `gc` against 145 KB under +`rc` - the collector is simply not there any more. + +**Layout.** `defaultlib/{lib,dll}/{debug,release}/{gc,rc,none}/`, one directory per (kind, build, +model). The model directory is named by `memoryModelName()`, the same function that spells the +`-mm=` flag and the shared-library marker symbol, so the three cannot drift apart. +`getDefaultLibSubDir` in `Defines.h` composes it and both consumers - the linker path in +`exe.cpp` and the JIT's shared-library list in `jit.cpp` - go through it. + +**No fallback, and a diagnostic rather than a linker error.** Asking for a model that has not +been built now says so: + +``` +error: no default library built for -mm=rc: ...\defaultlib\lib\release\rc does not exist. +Build it (see the default-lib build scripts), or compile with --no-default-lib. +``` + +Without the check it reached lld as a `-L` to nowhere and came back as `cannot open input file +'TypeScriptDefaultLib.lib'`, which names neither the model nor the remedy. Falling back to +another model's build would be worse than either: it links, and then misbehaves at run time. + +**Building it.** `build.bat` builds all three models for both configurations (twelve artifacts); +`build.bat release rc` builds one. `build.sh` on Linux takes the same two arguments and does the +same thing, passing the model down to `scripts/build.sh` as its fourth argument (after the +compiler and `pic`, so the existing three keep their positions). The install step needed no +change on either platform - `xcopy /e` and `cp -r` already copy whatever subdirectories are +there. + +One leftover worth knowing: the artifacts at the *old* paths (`lib/release/TypeScriptDefaultLib.lib` +and friends, with no model directory) are now dead, since nothing looks there any more. The build +script clears only the model directory it is writing, so they survive until deleted by hand. + +> **§9.7's larger case is closed by §9.77.** The default lib is now built per model and a +> program links the one matching its own `-mm=`. What remains of §9.7 is the general mixed-link +> question for *user* libraries, where the policy is unchanged: allow, warn, and leak. diff --git a/tslang/include/TypeScript/AsyncGCThreads.h b/tslang/include/TypeScript/AsyncGCThreads.h new file mode 100644 index 000000000..683627251 --- /dev/null +++ b/tslang/include/TypeScript/AsyncGCThreads.h @@ -0,0 +1,88 @@ +#ifndef MLIR_TYPESCRIPT_ASYNCGCTHREADS_H_ +#define MLIR_TYPESCRIPT_ASYNCGCTHREADS_H_ + +// Shared by the two copies of the async runtime - the one inside TypeScriptRuntime.dll that the +// JIT resolves against, and TypeScriptAsyncRuntime.lib that an ahead-of-time build links. +// +// A coroutine is resumed on one of the runtime's pool threads, and under `-mm=gc` its frame and +// everything its body builds come from the collector: the frame is handed back with GC_free at the +// end of the resume. Two things follow from that, and neither was being done. +// +// The one that crashed: Boehm does not lock its allocator until it is told there is more than one +// thread. `GC_need_to_lock` starts FALSE and LOCK()/UNLOCK() expand to nothing, so a worker and +// the awaiting thread walked the same free lists at once with no lock at all. That is what made +// `-mm=gc` fault on a long chain of awaits - about 1 run in 4 at 200k frames, never at 50k, never +// under `rc` or `none`, whose frames go to the CRT heap. Nothing to do with collection: a 1 GB +// initial heap, which leaves nothing to collect, changed nothing, and running the tasks inline +// made it stop. `GC_allow_register_threads` is what sets the flag. +// +// The one that would have come next: a thread the collector has never heard of is not suspended +// during a collection and its stack is not scanned, so a frame held only in that worker's +// registers can be freed underneath it. Hence the registration below. +// +// Only a `gc` build calls GC_enable_threads - the GC pass injects the call beside GC_init, and +// that pass runs for no other model - so `rc` and `none` reach GCThreadRegistration with the flag +// still false and do nothing, which is right: they never initialize the collector at all. +// Boehm's own GC_is_init_called cannot stand in for the flag, because the collector initializes +// itself on first use and so answers yes in programs that never meant to collect anything. + +#define GC_THREADS +#include "gc.h" + +namespace typescript +{ +namespace asyncgc +{ + +inline bool &threadsEnabledFlag() +{ + static bool enabled = false; + return enabled; +} + +// Called once, from the entry point, before any coroutine can be handed to the pool. +inline void enableThreads() +{ + GC_allow_register_threads(); + threadsEnabledFlag() = true; +} + +// Registered per task rather than per thread because the pool offers no thread-entry hook. +// GC_register_my_thread answers GC_DUPLICATE for a thread that is already known, and this then +// leaves the registration alone - some other task on the same thread owns it. +class ThreadRegistration +{ + public: + ThreadRegistration() : registered(false) + { + if (!threadsEnabledFlag()) + { + return; + } + + struct GC_stack_base sb; + if (GC_get_stack_base(&sb) == GC_SUCCESS) + { + registered = GC_register_my_thread(&sb) == GC_SUCCESS; + } + } + + ~ThreadRegistration() + { + if (registered) + { + GC_unregister_my_thread(); + } + } + + ThreadRegistration(const ThreadRegistration &) = delete; + ThreadRegistration &operator=(const ThreadRegistration &) = delete; + + private: + bool registered; +}; + +} // namespace asyncgc +} // namespace typescript + +#endif // MLIR_TYPESCRIPT_ASYNCGCTHREADS_H_ diff --git a/tslang/include/TypeScript/Config.h b/tslang/include/TypeScript/Config.h index 978c62c21..a304f9ebe 100644 --- a/tslang/include/TypeScript/Config.h +++ b/tslang/include/TypeScript/Config.h @@ -52,7 +52,27 @@ #define USE_BOUND_FUNCTION_FOR_OBJECTS true #define MODULE_AS_NAMESPACE true -#define ENABLE_TYPED_GC true +// Typed (precise-heap) allocation of class instances via GC_malloc_explicitly_typed. +// +// Disabled: the per-class pointer bitmap that feeds GC_make_descriptor is generated +// incorrectly, so the "precision" it bought was never real. mlirGenClassTypeBitmap has +// three defects - it shifts right where it means to shift left (so no bit above position +// zero is ever set), it indexes the bitmap array by the field's word index within the +// object instead of that index divided by the word bit count (so it also runs off the end +// of the stack array), and the array is never zeroed because AllocaOpLowering still +// carries its "TODO: call MemSet". The descriptor therefore came from uninitialized stack +// memory, and any pointer field whose bit read as zero was left untraced - a live object +// could be collected. Short tests rarely trigger a collection, which is why this stayed +// latent. +// +// With this off, class instances take the same generic allocation path as everything else +// (NewOp -> NewOpLowering -> MemoryAlloc) and are scanned conservatively, which is what +// they effectively got anyway. That also gives them the heap block header uniformly - see +// docs/reference-counting-evaluation.md sections 4 and 9.1. +// +// Re-enabling requires fixing all three defects in mlirGenClassTypeBitmap first, and then +// shifting every bit by the header size, since descriptor bits are object-base-relative. +#define ENABLE_TYPED_GC false //#define ENABLE_DEBUGINFO_PATCH_INFO true diff --git a/tslang/include/TypeScript/DOM.h b/tslang/include/TypeScript/DOM.h index b33b9685e..a254c0caa 100644 --- a/tslang/include/TypeScript/DOM.h +++ b/tslang/include/TypeScript/DOM.h @@ -30,6 +30,7 @@ class VariableDeclarationDOM bool captured; bool ignoreCapturing; bool _using; + mlir::Value disposeGuard; public: using TypePtr = std::shared_ptr; @@ -105,6 +106,19 @@ class VariableDeclarationDOM _using = value; } + // A boolean slot saying whether this `using` declaration currently holds something that + // still owes a `[Symbol.dispose]()`. Only a declaration whose storage was hoisted out in + // front of a TryOp has one, because only that shape has a cleanup region able to dispose + // it a second time or before the first time. See mlirGenDisposeOne. + mlir::Value getDisposeGuard() const + { + return disposeGuard; + } + void setDisposeGuard(mlir::Value value) + { + disposeGuard = value; + } + void setAtomic(int ordering_, StringRef syncscope_) { atomic = true; diff --git a/tslang/include/TypeScript/DataStructs.h b/tslang/include/TypeScript/DataStructs.h index 98f48e311..df3709972 100644 --- a/tslang/include/TypeScript/DataStructs.h +++ b/tslang/include/TypeScript/DataStructs.h @@ -8,7 +8,7 @@ struct CompileOptions { bool isJit; - bool disableGC; + enum MemoryModel memoryModel; bool enableBuiltins; bool noDefaultLib; std::string defaultDeclarationTSFile; @@ -21,12 +21,40 @@ struct CompileOptions bool isWindows; bool isExecutable; bool isDLL; + + // Whether this compilation is building the module that holds the program's entry point, and + // so has to have a `main` even when the root holds no code to put in one. `--emit=jit` and + // `--emit=exe` answer that by themselves. `--emit=obj` cannot: the same action compiles the + // program and every library linked beside it, so a program built that way says so with + // `--entry-point`. Without that, a library whose root merely initializes a variable would + // define `main` too, and two of them fail to link with "duplicate symbol: main". + bool generateEntryPoint; enum Exports exportOpt; bool embedExportDeclarations; std::string outputFolder; bool appendGCtorsToMethod; bool strictNullChecks; bool enableFastMath; + + // Whether the Boehm runtime has to be present. Only `gc` needs it: it is the model whose + // reclamation *is* the collector. `rc` frees through the reference counts it maintains and + // `none` frees nothing, so both allocate straight from `malloc` and neither links libgc. + // + // This was true for `rc` too while the retain/release insertion points were being built + // (§9.6 through §9.27). Boehm collecting behind them made a missing release invisible, which + // was the point at the time - but it also meant no memory measurement taken under `rc` said + // anything about reference counting, since the collector was doing the reclaiming either + // way. See docs/reference-counting-evaluation.md §9.28. + bool needsGCRuntime() const + { + return memoryModel == MemoryModelGC; + } + + // Whether allocations maintain a reference count in the block header. + bool isRefCounted() const + { + return memoryModel == MemoryModelRC; + } }; #endif // TYPESCRIPT_DATASTRUCT_H_ \ No newline at end of file diff --git a/tslang/include/TypeScript/Defines.h b/tslang/include/TypeScript/Defines.h index a2811796d..c460e079e 100644 --- a/tslang/include/TypeScript/Defines.h +++ b/tslang/include/TypeScript/Defines.h @@ -1,6 +1,8 @@ #ifndef DEFINES_H_ #define DEFINES_H_ +#include + #define IDENTIFIER_ATTR_NAME "identifier" #define BUILTIN_FUNC_ATTR_NAME "__builtin" #define GENERIC_ATTR_NAME "__generic" @@ -11,6 +13,51 @@ #define NONTEMPORAL_ATTR_NAME "__nontemporal" #define INVARIANT_ATTR_NAME "__invariant" #define INSTANCES_COUNT_ATTR_NAME "InstancesCount" +// Marks a local's storage as holding a reference the scope owns, so that assigning through it +// hands the count over rather than dropping a reference nobody took. Only variable +// declarations set it, which is what keeps parameters and fields - references the frame +// borrows rather than owns - out of the assignment path. See MLIRGen's takeOwnershipOfLocal. +#define OWNED_LOCAL_ATTR_NAME "__owned" + +// Marks an operation whose result already carries a reference the receiver is expected to take +// over, rather than one it must retain for itself. `new C()` is the case that matters: it lowers +// to a call of the generated `C..new`, and every function retains its result before returning +// (§9.24), so the value arrives owned. A receiver that retained it again would be one owner +// above the truth, which is exactly the leak §9.25 removes. +// +// Only set where the producer is known to retain - never inferred from a call being a call. A +// runtime or builtin helper, or a function imported from a module built before that convention, +// returns a heap value without any retain, and treating one of those as owned would skip a +// retain nobody performed and free live memory. +#define OWNED_RESULT_ATTR_NAME "__owned_result" + +// Marks an owned local that took its reference by consuming an OWNED_RESULT_ATTR_NAME value +// instead of by retaining. The slot still releases at every scope exit - that release is what +// gives the consumed reference back - so the pair is still balanced, but there is no +// `ts.RetainSlot` to pair the release with. The ownership verifier reads this attribute as the +// retain it stands in for. +#define OWNED_LOCAL_CONSUMED_ATTR_NAME "__owned_consumed" + +// Marks an OWNED_RESULT_ATTR_NAME operation whose reference some receiver has taken over, so the +// +1 it produced is now somebody's to give back. Set at each of the receiving sites (§9.25) and +// by OwnedReturnConsumptionPass (§9.27) when it removes a receiver's retain. +// +// Its absence is what identifies a discarded temporary: an operation that produced a reference +// nothing took. `f();` on its own, and - far more commonly - a call result used as an argument +// and then dropped, which is what expression-shaped code is made of. See §9.30. +#define OWNED_RESULT_CONSUMED_ATTR_NAME "__owned_result_consumed" + +// Marks a `ts.CreateBoundFunction` whose `this` is a capture box built for it a moment earlier, +// rather than a receiver that belongs to somebody else. Only such a closure owns its `this`, and +// only it gets the type tag at CLOSURE_TYPE_INDEX - a bound method must not take ownership of +// the object it is bound to. Set where the closure is built and the difference is known. +#define OWNS_CAPTURE_ATTR_NAME "__owns_capture" + +// Marks the `ts.Variable` that CaptureOpLowering creates for a capture box. It carries +// `captured = true` only because that is how a variable asks to be allocated in the heap, and +// without this marker it would be indistinguishable from the cell of a captured variable - and +// so would be given a frame's reference it has no owner for. A box's owner is the closure. +#define CAPTURE_BOX_ATTR_NAME "__capture_box" #define RETURN_VARIABLE_NAME ".return" #define CAPTURED_NAME ".captured" #define LABEL_ATTR_NAME "label" @@ -64,6 +111,15 @@ #define SHARED_LIB_DECLARATIONS_FILENAME "__decls.ts" #define SHARED_LIB_DECLARATIONS_2UNDERSCORE "__decls" #define SHARED_LIB_DECLARATIONS "___decls" +// A shared library records the memory model it was built under as an exported data symbol +// named "__tsmm___" - the model is in the NAME, so an importer reads it by +// enumerating symbols and never has to load the data. Deliberately not "__decls"-prefixed, so +// it can never reach the declaration re-parser. +// +// Objects allocated by a module built under a different model must not be freed by this one: +// see docs/reference-counting-evaluation.md section 4. A missing marker means a module built +// before this existed, which is always garbage-collected. +#define SHARED_LIB_MEMORY_MODEL "__tsmm_" #define DLL_EXPORT "dllexport" #define DLL_IMPORT "dllimport" @@ -80,6 +136,28 @@ #define DATA_VALUE_INDEX 0 #define THIS_VALUE_INDEX 1 +// An interface value is { vtable, this, type } - the first two share the indexes above with +// every other pair-shaped value. The third is the runtime type tag of whatever `this` points +// at, which an interface needs for the same reason an `any` box does: the interface type +// carries only a name, so the layout behind `this` is not recoverable from it. With the tag +// there, an interface can be released and retained like anything else, through the concrete +// type's own routines (see OwnershipRoutineLogic and section 9.31). +// +// Null when `this` owns no heap memory - a null interface, or one made from a value that +// carries nothing. +#define INTERFACE_TYPE_INDEX 2 + +// A bound or hybrid function value is { func, this, type }, and the third word is there for the +// same reason as an interface's: the `this` of a closure is its capture box, heap-allocated and +// not named anywhere in the function type, so nothing could give it back. The tag makes it +// releasable through the box's own routine. +// +// Null for every function value that is not a closure over captured variables - a plain function +// pointer, a bound method, an interface's method slot - which is what keeps `obj.m` from taking +// ownership of `obj`. Only CreateBoundFunctionOp builds one of these, so there is no path that +// leaves the slot undefined. See section 9.33. +#define CLOSURE_TYPE_INDEX 2 + #define ARRAY_DATA_INDEX 0 #define ARRAY_SIZE_INDEX 1 @@ -93,6 +171,70 @@ #define ANY_TYPE 1 #define ANY_DATA 2 +// Every heap block reserves one index-sized word in front of its payload (see +// LLVMCodeHelperBase::getHeapBlockHeaderSize). Static blocks - string literals - carry the +// same word, set to this marker, so that a payload pointer is one shape whether it names +// heap or static storage and a release can tell the two apart before calling free. +// +// All bits set, so the marker is the same value whatever the word size or endianness, and it +// is not a value a real count reaches. It is deliberately not zero: a zeroed word is what a +// fresh heap block reads. +// +// Under `-mm=rc` the word holds a live reference count: _MemoryAlloc writes zero into it, and +// a block is freed when a release takes it back to zero (§9.6, §9.24). Under `gc` and `none` +// nothing reads it and nothing writes it - only the static side below is pinned in every model, +// because it is the side that changes a global's layout and so cannot be retrofitted without an +// ABI break. See docs/reference-counting-evaluation.md sections 9.5 and 9.24. +#define HEAP_BLOCK_IMMORTAL -1 + +// Runtime type descriptor. +// +// The ANY_TYPE slot of an "any" box, and the UNION_TAG_INDEX slot of a tagged union, both +// hold a "type tag": a pointer to the NUL-terminated type name, which is what `typeof` +// returns. That name is stored immediately after a fixed-size descriptor record, so the +// descriptor for a tag is reachable at `tag - sizeof(descriptor)` - the same +// header-in-front-of-payload arrangement used for heap blocks. The trailing name is a byte +// array, so it never needs padding in front of it and that offset is exactly the record +// size on every target. +// +// This makes the layout below a cross-module contract even though each module emits its +// own internal-linkage descriptors: a tag produced by one module is read back by another. +// Fields may be added, but never reordered or resized, and a new one goes immediately before +// the block header, which has to stay last - see TYPE_DESCR_BLOCK_HEADER. +#define TYPE_DESCR_KIND 0 +#define TYPE_DESCR_RESERVED 1 +// Address of the type's release routine, or null when the type owns no heap memory - null +// says "nothing to release", not "unknown". Generated by OwnershipRoutineLogic. +#define TYPE_DESCR_RELEASE 2 +// Address of the type's retain routine, on the same terms as the release slot. Both exist +// because a tagged union carries its payload inline: copying or dropping one has to retain +// or release a value whose type is only known at run time, and the tag is what knows it. +#define TYPE_DESCR_RETAIN 3 +// The block header, last so that it sits immediately in front of the name bytes. A tag is a +// `typeof` result, and `typeof x` can be assigned to a `string` and released like any other +// string - so a tag has to look like a payload with an immortal block header, exactly as a +// string literal does, on top of being a name preceded by a descriptor. Both reads work off +// the same pointer: `tag - sizeof(header)` is the marker, `tag - sizeof(record)` the record. +#define TYPE_DESCR_BLOCK_HEADER 4 + +// Coarse category of the described type. These correspond one-to-one with the names +// TypeOfOpHelper::typeOfAsString reports, and are derived from that name so the two cannot +// drift apart - see TypeOfOpHelper::typeKindFromName. +#define TYPE_KIND_UNKNOWN 0 +#define TYPE_KIND_NUMBER 1 +#define TYPE_KIND_STRING 2 +#define TYPE_KIND_BOOLEAN 3 +#define TYPE_KIND_CHAR 4 +#define TYPE_KIND_ARRAY 5 +#define TYPE_KIND_TUPLE 6 +#define TYPE_KIND_OBJECT 7 +#define TYPE_KIND_CLASS 8 +#define TYPE_KIND_INTERFACE 9 +#define TYPE_KIND_FUNCTION 10 +#define TYPE_KIND_SYMBOL 11 +#define TYPE_KIND_UNDEFINED 12 +#define TYPE_KIND_NULL 13 + #define DEFAULT_LIB_DIR "defaultlib" #define DEFAULT_LIB_NAME "TypeScriptDefaultLib" @@ -103,6 +245,24 @@ #define DEFAULT_LIB_BUILD_DIR_RELEASE "release" #define DEFAULT_LIB_BUILD_DIR_DEBUG "debug" +// ...and then per memory model, because a default lib is not model-neutral. Under `-mm=gc` it +// allocates through Boehm and drags libgc in with it; under `-mm=rc` it initialises the block +// header's reference count and follows the +1 return convention (§9.24); under `-mm=none` it +// does neither. Linking one model's library into another model's program is the case §9.7 +// warns about, and it is the largest instance of it, since every program that does not pass +// `--no-default-lib` links this one. +// +// Layout: defaultlib/{lib,dll}/{debug,release}/{gc,rc,none}/. The model name is exactly +// `memoryModelName()`, so the directory and the `-mm=` flag cannot drift apart. +#define DEFAULT_LIB_KIND_STATIC "lib" +#define DEFAULT_LIB_KIND_SHARED "dll" + +inline std::string getDefaultLibSubDir(bool shared, bool debugBuild, const char *memoryModel) +{ + return std::string(DEFAULT_LIB_DIR "/") + (shared ? DEFAULT_LIB_KIND_SHARED : DEFAULT_LIB_KIND_STATIC) + "/" + + (debugBuild ? DEFAULT_LIB_BUILD_DIR_DEBUG : DEFAULT_LIB_BUILD_DIR_RELEASE) + "/" + memoryModel; +} + #define DEBUG_SCOPE "current" #define CU_DEBUG_SCOPE "compileUnit" #define FILE_DEBUG_SCOPE "file" diff --git a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h index c546368ea..029c95a4d 100644 --- a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h @@ -634,8 +634,10 @@ class CastLogicHelper if (auto undefType = dyn_cast(inType)) { in.getDefiningOp()->emitWarning("using casting to undefined value"); + // the `mlir_ts::UndefOp` this makes is where an owning type is turned into null + // instead of undef - see UndefOpLowering return rewriter.create(loc, resType); - } + } return mlir::Value(); } @@ -1037,12 +1039,23 @@ class CastLogicHelper auto destArrayElement = mlir::cast(arrayType).getElementType(); auto llvmDestArrayElement = tch.convertType(destArrayElement); - auto structValue = rewriter.create(loc, llvmRtArrayStructType); if (isUndef) { - return structValue; + // `undefined` as an array has to be an empty array rather than undef under + // reference counting: whoever receives it retains it, and reaching an undef + // pointer's block header is undefined behaviour. An iterator's final + // `{ value: undefined, done: true }` is built exactly this way, and the caller + // retains the result before it looks at `done`. + if (compileOptions.isRefCounted()) + { + return rewriter.create(loc, llvmRtArrayStructType).getResult(); + } + + return rewriter.create(loc, llvmRtArrayStructType).getResult(); } - + + auto structValue = rewriter.create(loc, llvmRtArrayStructType); + auto arrayValueSize = LLVM::LLVMArrayType::get(llvmSrcElementType, size); mlir::Value arrayPtr; diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h index cde3f8fbc..5f76a081a 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h @@ -14,6 +14,7 @@ #include "TypeScript/LowerToLLVM/CodeLogicHelper.h" #include "TypeScript/LowerToLLVM/CastLogicHelper.h" #include "TypeScript/LowerToLLVM/LLVMCodeHelperBase.h" +#include "TypeScript/LowerToLLVM/TypeDescriptorLogic.h" #include "mlir/Transforms/DialectConversion.h" #include "mlir/Dialect/Func/IR/FuncOps.h" @@ -344,6 +345,83 @@ class LLVMCodeHelper : public LLVMCodeHelperBase return rewriter.getStringAttr(StringRef(value.data(), value.length() + 1)); } + // Emits, once per concrete type, the static descriptor for that type, and returns a + // pointer to its trailing name bytes. That pointer is the runtime type tag: it reads as + // an ordinary NUL-terminated type name, and the record is at `tag - sizeof(record)`. + // See TYPE_DESCR_* in Defines.h. + mlir::Value getOrCreateTypeDescriptorName(mlir::Type type, std::string name, int kind, + StringRef releaseRoutineName, StringRef retainRoutineName) + { + auto loc = op->getLoc(); + auto parentModule = op->getParentOfType(); + + TypeHelper th(rewriter); + + // keyed by the concrete type rather than by the name: every class reports the name + // "class", but each needs its own record, which is the point of having one at all + std::stringstream varName; + varName << "td_" << (size_t)hash_value(type) << "_" << name; + + TypeConverterHelper tch(typeConverter); + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto recordType = TypeDescriptorLogic::getRecordType(rewriter, llvmIndexType); + auto nameArrayType = th.getArrayType(th.getI8Type(), name.length() + 1); + auto descriptorType = LLVM::LLVMStructType::getLiteral(rewriter.getContext(), {recordType, nameArrayType}, false); + + LLVM::GlobalOp global; + if (!(global = parentModule.lookupSymbol(varName.str()))) + { + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + seekLast(parentModule.getBody()); + + global = rewriter.create(loc, descriptorType, true, LLVM::Linkage::Internal, varName.str(), + mlir::Attribute{}); + + setStructWritingPoint(global); + + auto i32Ty = th.getI32Type(); + + mlir::Value recordValue = rewriter.create(loc, recordType); + setStructValue(loc, recordValue, rewriter.create(loc, i32Ty, rewriter.getI32IntegerAttr(kind)), + TYPE_DESCR_KIND); + setStructValue(loc, recordValue, rewriter.create(loc, i32Ty, rewriter.getI32IntegerAttr(0)), + TYPE_DESCR_RESERVED); + // empty when the type owns no heap memory, which a null slot states positively: + // "nothing to release", rather than leaving it unknown + mlir::Value releaseValue = + releaseRoutineName.empty() + ? (mlir::Value)rewriter.create(loc, th.getPtrType()) + : (mlir::Value)rewriter.create(loc, th.getPtrType(), releaseRoutineName); + setStructValue(loc, recordValue, releaseValue, TYPE_DESCR_RELEASE); + mlir::Value retainValue = + retainRoutineName.empty() + ? (mlir::Value)rewriter.create(loc, th.getPtrType()) + : (mlir::Value)rewriter.create(loc, th.getPtrType(), retainRoutineName); + setStructValue(loc, recordValue, retainValue, TYPE_DESCR_RETAIN); + // a tag doubles as a string payload, so what precedes the name has to read as an + // immortal block header - see TYPE_DESCR_BLOCK_HEADER + setStructValue(loc, recordValue, + rewriter.create( + loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)), + TYPE_DESCR_BLOCK_HEADER); + + mlir::Value descriptorValue = rewriter.create(loc, descriptorType); + setStructValue(loc, descriptorValue, recordValue, 0); + setStructValue(loc, descriptorValue, + rewriter.create(loc, nameArrayType, getStringAttrWith0(name)), 1); + + rewriter.create(loc, ValueRange{descriptorValue}); + + // the header immediately before the name is read as a whole word + global.setAlignment(getHeapBlockHeaderSize()); + } + + mlir::Value globalPtr = rewriter.create(loc, global); + return rewriter.create(loc, th.getPtrType(), descriptorType, globalPtr, ArrayRef{0, 1, 0}); + } + mlir::Value getOrCreateGlobalArray(mlir::Type originalElementType, unsigned size, ArrayAttr arrayAttr) { std::stringstream ss; @@ -470,6 +548,13 @@ class LLVMCodeHelper : public LLVMCodeHelperBase auto ptrType = th.getPtrType(); auto arrayType = th.getArrayType(llvmElementType, size); + // A constant array is a block like any other under reference counting: an element of it + // can be bound to a local that takes a reference, so the same header word has to sit in + // front of the data, marked immortal - exactly as it does for a string literal. Without + // it a retain reads, and a release writes, the word before a read-only global. + auto withHeader = compileOptions.isRefCounted(); + auto headerSize = getHeapBlockHeaderSize(); + // Create the global at the entry of the module. LLVM::GlobalOp global; if (!(global = parentModule.lookupSymbol(name))) @@ -479,14 +564,15 @@ class LLVMCodeHelper : public LLVMCodeHelperBase // dense value auto value = arrayAttr.getValue(); - if (value.size() > 0 && llvmElementType.isIntOrIndexOrFloat()) - { - seekLast(parentModule.getBody()); + auto isDense = value.size() > 0 && llvmElementType.isIntOrIndexOrFloat(); - // end - auto dataType = mlir::VectorType::get({static_cast(value.size())}, llvmElementType); + mlir::Type dataType = arrayType; + DenseElementsAttr denseAttr; + if (isDense) + { + auto vectorType = mlir::VectorType::get({static_cast(value.size())}, llvmElementType); + dataType = vectorType; - DenseElementsAttr attr; if (llvmElementType.isIntOrIndex()) { SmallVector values; @@ -494,7 +580,7 @@ class LLVMCodeHelper : public LLVMCodeHelperBase values.push_back(cast(value_).getValue()); }); - attr = DenseElementsAttr::get(dataType, values); + denseAttr = DenseElementsAttr::get(vectorType, values); } else { @@ -503,10 +589,47 @@ class LLVMCodeHelper : public LLVMCodeHelperBase values.push_back(cast(value_).getValue()); }); - attr = DenseElementsAttr::get(dataType, values); + denseAttr = DenseElementsAttr::get(vectorType, values); } + } + + if (withHeader) + { + seekLast(parentModule.getBody()); + + OpBuilder::InsertionGuard guard(rewriter); + + // packed, because the header has to sit exactly one word in front of the data: + // an unpacked struct pads to the data's own alignment, and a vector of three + // i32 wants sixteen bytes, which would put the header two words back instead + auto blockType = LLVM::LLVMStructType::getLiteral(rewriter.getContext(), {llvmIndexType, dataType}, + /*isPacked=*/true); + global = rewriter.create(loc, blockType, true, LLVM::Linkage::Internal, name, mlir::Attribute{}); - global = rewriter.create(loc, /*arrayType*/dataType, true, LLVM::Linkage::Internal, name, attr); + setStructWritingPoint(global); + + mlir::Value blockVal = rewriter.create(loc, blockType); + blockVal = rewriter.create( + loc, blockVal, + rewriter.create(loc, llvmIndexType, + rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)), + MLIRHelper::getStructIndex(rewriter, 0)); + + mlir::Value dataVal = isDense + ? (mlir::Value)rewriter.create(loc, dataType, denseAttr) + : getArrayValue(originalElementType, llvmElementType, size, arrayAttr); + blockVal = rewriter.create(loc, blockVal, dataVal, MLIRHelper::getStructIndex(rewriter, 1)); + + rewriter.create(loc, ValueRange{blockVal}); + + // the header is read as a whole word, so the block base has to be word-aligned + global.setAlignment(headerSize); + } + else if (isDense) + { + seekLast(parentModule.getBody()); + + global = rewriter.create(loc, /*arrayType*/dataType, true, LLVM::Linkage::Internal, name, denseAttr); } else { @@ -524,9 +647,10 @@ class LLVMCodeHelper : public LLVMCodeHelperBase } } - // Get the pointer to the first character in the global string. + // Get the pointer to the first element - past the header, when there is one. mlir::Value globalPtr = rewriter.create(loc, global); - return rewriter.create(loc, ptrType, global.getType(), globalPtr, ArrayRef{0, 0}); + return withHeader ? rewriter.create(loc, ptrType, global.getType(), globalPtr, ArrayRef{0, 1}) + : rewriter.create(loc, ptrType, global.getType(), globalPtr, ArrayRef{0, 0}); } mlir::LogicalResult setStructWritingPoint(LLVM::GlobalOp globalOp) @@ -593,7 +717,18 @@ class LLVMCodeHelper : public LLVMCodeHelperBase { LLVM_DEBUG(llvm::dbgs() << "!! Unit Attr is type of '" << llvmType << "'\n"); - auto itemValue = rewriter.create(loc, llvmType); + // An unspecified field of an owning type must be null rather than undef: under + // reference counting the tuple is retained as a whole before anything writes the + // field (a generator's state object is built exactly this way), and walking an + // undef pointer to reach its header is undefined behaviour, which the optimizer + // is entitled to - and does - fold the whole caller away for. + auto unspecifiedFieldIsOwning = compileOptions.isRefCounted() && + MLIRTypeHelper(rewriter.getContext(), compileOptions).ownsHeapMemory(loc, type); + + mlir::Value itemValue = unspecifiedFieldIsOwning + ? rewriter.create(loc, llvmType).getResult() + : rewriter.create(loc, llvmType).getResult(); + tupleVal = rewriter.create(loc, tupleVal, itemValue, MLIRHelper::getStructIndex(rewriter, position++)); } else if (auto stringAttr = dyn_cast(item)) diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h index ee661814e..dbdb0eeb3 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h @@ -146,6 +146,14 @@ class LLVMCodeHelperBase auto llvmIndexType = tch.convertType(th.getIndexType()); + // A static string is a block like any other: the same header word sits in front of + // the characters, marked immortal, so a pointer to a string literal and a pointer to + // a heap string are the same shape and a release can tell them apart. All-ones bytes + // encode HEAP_BLOCK_IMMORTAL whatever the word size or endianness. + auto headerSize = getHeapBlockHeaderSize(); + std::string blockBytes(headerSize, (char)0xFF); + blockBytes.append(value.data(), value.size()); + // Create the global at the entry of the module. LLVM::GlobalOp global; if (!(global = parentModule.lookupSymbol(name))) @@ -155,13 +163,16 @@ class LLVMCodeHelperBase seekLast(parentModule.getBody()); - auto type = th.getArrayType(th.getI8Type(), value.size()); - global = rewriter.create(loc, type, true, LLVM::Linkage::Internal, name, rewriter.getStringAttr(value)); + auto type = th.getArrayType(th.getI8Type(), blockBytes.size()); + global = rewriter.create(loc, type, true, LLVM::Linkage::Internal, name, rewriter.getStringAttr(blockBytes)); + // the header is read as a whole word, so the block base has to be word-aligned + global.setAlignment(headerSize); } - // Get the pointer to the first character in the global string. + // Get the pointer to the first character in the global string - past the header. mlir::Value globalPtr = rewriter.create(loc, global); - return rewriter.create(loc, th.getPtrType(), global.getType(), globalPtr, ArrayRef{0, 0}); + return rewriter.create(loc, th.getPtrType(), global.getType(), globalPtr, + ArrayRef{0, (int32_t)headerSize}); } public: @@ -250,20 +261,77 @@ class LLVMCodeHelperBase return allocated; } + // === Heap block header (memory-model groundwork) === + // + // Every heap block allocated through _MemoryAlloc reserves a leading pointer-sized word, + // and the pointer handed back to the rest of the compiler addresses the payload just past + // it. Under GC that word is never read; under `-mm=rc` it is the reference count. Keeping + // the layout identical in both memory models is what makes a GC-built module and an + // RC-built module safe to link together; see docs/reference-counting-evaluation.md, + // sections 4 and 9.1. + // + // This covers every heap block that the compiler still emits, class instances included. + // The one path it would not have covered - GC_malloc_explicitly_typed, whose Boehm type + // descriptor indexes bits relative to the object base and so could not tolerate the base + // moving - sits behind ENABLE_TYPED_GC and was retired in §9.2, before the header existed. + unsigned getHeapBlockHeaderSize() + { + return compileOptions.sizeBits / 8; + } + + mlir::Value createHeapBlockHeaderSizeConstant(mlir::Location loc, mlir::Type llvmIndexType, bool negated = false) + { + auto bytes = static_cast(getHeapBlockHeaderSize()); + return rewriter.create(loc, llvmIndexType, + rewriter.getIntegerAttr(llvmIndexType, negated ? -bytes : bytes)); + } + + // payload = block + headerSize + mlir::Value getPayloadPtrFromBlockPtr(mlir::Location loc, mlir::Value blockPtr, mlir::Type llvmIndexType) + { + TypeHelper th(rewriter); + auto offset = createHeapBlockHeaderSizeConstant(loc, llvmIndexType); + return rewriter.create(loc, th.getPtrType(), th.getI8Type(), blockPtr, ValueRange{offset}); + } + + // block = payload - headerSize + mlir::Value getBlockPtrFromPayloadPtr(mlir::Location loc, mlir::Value payloadPtr, mlir::Type llvmIndexType) + { + TypeHelper th(rewriter); + auto offset = createHeapBlockHeaderSizeConstant(loc, llvmIndexType, /*negated=*/true); + return rewriter.create(loc, th.getPtrType(), th.getI8Type(), payloadPtr, ValueRange{offset}); + } + template mlir::Value _MemoryAlloc(mlir::Value sizeOfAlloc, MemoryAllocSet memAllocMode) { TypeHelper th(rewriter); TypeConverterHelper tch(typeConverter); - CodeLogicHelper clh(op, rewriter); auto llvmIndexType = tch.convertType(th.getIndexType()); auto loc = op->getLoc(); + // A zeroed block is asked for as `calloc`, not as `malloc` followed by a zero-fill, and + // that is not cosmetic. LLVM recognises the pair and rewrites it into `calloc` itself - + // DeadStoreElimination's tryFoldIntoCalloc - and it builds the replacement call without + // carrying the original's operand bundles over. A pair sitting inside a Win64 catch + // funclet therefore loses its `funclet` bundle, WinEHPrepare stops seeing those + // instructions as part of the funclet, and the handler is emitted as a bare prologue + // with no body and no catchret: it faults the moment it runs. `new` inside a `catch` + // was enough to hit it. Asking for calloc outright leaves nothing for that fold to + // rewrite. Only `gc` was ever safe here, and by accident - GCPass deletes the zero-fill, + // so the pattern never survived to LLVM. See §9.28. + // + // The wasm allocator fork has no `ts_calloc`, and no Win64 funclets either, so it keeps + // the two-step form. + auto zeroing = memAllocMode == MemoryAllocSet::Zero; + auto useCalloc = zeroing && !compileOptions.isWasm; + auto i8PtrTy = th.getPtrType(); - auto mallocFuncOp = getOrInsertFunction( - compileOptions.isWasm ? "ts_malloc" : "malloc", - th.getFunctionType(i8PtrTy, {llvmIndexType})); + auto mallocFuncOp = useCalloc + ? getOrInsertFunction("calloc", th.getFunctionType(i8PtrTy, {llvmIndexType, llvmIndexType})) + : getOrInsertFunction(compileOptions.isWasm ? "ts_malloc" : "malloc", + th.getFunctionType(i8PtrTy, {llvmIndexType})); auto effectiveSize = sizeOfAlloc; @@ -277,23 +345,68 @@ class LLVMCodeHelperBase effectiveSize = rewriter.create(loc, llvmIndexType, effectiveSize); } - auto callResults = rewriter.create(loc, mallocFuncOp, ValueRange{effectiveSize}); - if (memAllocMode == MemoryAllocSet::Atomic) + // reserve the block header in front of the payload + auto headerSizeValue = createHeapBlockHeaderSizeConstant(loc, llvmIndexType); + mlir::Value paddedSize = rewriter.create(loc, llvmIndexType, ValueRange{effectiveSize, headerSizeValue}); + + mlir::Value blockPtr; + if (useCalloc) { - callResults->setAttr("mode", rewriter.getStringAttr("atomic")); + auto oneValue = + rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1)); + blockPtr = rewriter.create(loc, mallocFuncOp, ValueRange{oneValue, paddedSize}).getResult(); } + else + { + auto callResults = rewriter.create(loc, mallocFuncOp, ValueRange{paddedSize}); + if (memAllocMode == MemoryAllocSet::Atomic) + { + callResults->setAttr("mode", rewriter.getStringAttr("atomic")); + } - auto ptr = callResults.getResult(); + blockPtr = callResults.getResult(); - if (memAllocMode == MemoryAllocSet::Zero) + if (zeroing) + { + // wasm only, per useCalloc above. Zero the whole block, header included, rather + // than just the payload, so the destination stays the allocation call itself - + // which is what GCPass's redundant-zero-fill check matches on. + // + // The intrinsic rather than a call to `memset`: `ts_malloc` is not a name LLVM + // recognises, so the calloc fold described above cannot reach this pair, but the + // intrinsic is the form LLVM converts a memset call into at -O1 anyway and the + // one this code always meant to emit. + auto const0 = rewriter.create(loc, th.getI8Type(), rewriter.getI8IntegerAttr(0)); + rewriter.create(loc, blockPtr, const0, paddedSize, /*isVolatile=*/false); + } + } + + if (compileOptions.isRefCounted()) { - // TODO: replace with @llvm.memset.p0.i64 & @llvm.memset.p0.i32 - auto memsetFuncOp = getOrInsertFunction("memset", th.getFunctionType(i8PtrTy, {i8PtrTy, th.getI32Type(), llvmIndexType})); - auto const0 = clh.createI32ConstantOf(0); - rewriter.create(loc, memsetFuncOp, ValueRange{ptr, const0, effectiveSize}); + // The 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. + // + // It was born at one until §9.24: the reference an allocation came with was never + // consumed, so every count sat one above the truth and nothing was ever freed. That + // was deliberate while the insertion points were being built one at a time, because + // it made a missing retain an inert leak rather than a premature free. All of them + // are in place now (§9.19 through §9.23), so the slack comes out here. + // + // 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. + // + // Written after the zeroing above, which covers the header along with the payload. + // Only under `-mm=rc` -- under `gc` nothing reads the word, and a store per + // allocation on the hot path is not worth paying for dead code. + rewriter.create( + loc, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 0)), + blockPtr); } - return ptr; + return getPayloadPtrFromBlockPtr(loc, blockPtr, llvmIndexType); } template mlir::Value _MemoryRealloc(mlir::Value ptrValue, mlir::Value sizeOfAlloc) @@ -323,8 +436,14 @@ class LLVMCodeHelperBase effectiveSize = rewriter.create(loc, llvmIndexType, effectiveSize); } - auto callResults = rewriter.create(loc, mallocFuncOp, ValueRange{ptrValue, effectiveSize}); - return callResults.getResult(); + // the incoming pointer addresses the payload; realloc must see the block base, and the + // block must stay large enough for the header it carries + auto headerSizeValue = createHeapBlockHeaderSizeConstant(loc, llvmIndexType); + mlir::Value paddedSize = rewriter.create(loc, llvmIndexType, ValueRange{effectiveSize, headerSizeValue}); + auto blockPtrValue = getBlockPtrFromPayloadPtr(loc, ptrValue, llvmIndexType); + + auto callResults = rewriter.create(loc, mallocFuncOp, ValueRange{blockPtrValue, paddedSize}); + return getPayloadPtrFromBlockPtr(loc, callResults.getResult(), llvmIndexType); } template mlir::LogicalResult _MemoryFree(mlir::Value ptrValue) @@ -342,7 +461,11 @@ class LLVMCodeHelperBase auto casted = rewriter.create(loc, i8PtrTy, ptrValue); - rewriter.create(loc, freeFuncOp, ValueRange{casted}); + // the incoming pointer addresses the payload; free must see the block base + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto blockPtrValue = getBlockPtrFromPayloadPtr(loc, casted, llvmIndexType); + + rewriter.create(loc, freeFuncOp, ValueRange{blockPtrValue}); return mlir::success(); } diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32.h b/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32.h index 82035e0aa..f958475e9 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32.h @@ -33,6 +33,9 @@ class LLVMRTTIHelperVCWin32 std::string typeName; std::string typeInfoRef; std::string catchableTypeInfoRef; + // bytes the CRT copies into the catch variable's slot - see catchableTypeSize in + // LLVMRTTIHelperVCWin32Const.h for why getting this wrong corrupts the frame + int catchableTypeSize; }; Operation *op; @@ -40,6 +43,7 @@ class LLVMRTTIHelperVCWin32 ModuleOp parentModule; TypeHelper th; LLVMCodeHelper ch; + CompileOptions &compileOptions; SmallVector types; @@ -48,14 +52,15 @@ class LLVMRTTIHelperVCWin32 std::string throwInfoRef; LLVMRTTIHelperVCWin32(Operation *op, PatternRewriter &rewriter, const TypeConverter *typeConverter, CompileOptions &compileOptions) - : op(op), rewriter(rewriter), parentModule(op->getParentOfType()), th(rewriter), ch(op, rewriter, typeConverter, compileOptions) + : op(op), rewriter(rewriter), parentModule(op->getParentOfType()), th(rewriter), + ch(op, rewriter, typeConverter, compileOptions), compileOptions(compileOptions) { // setI32AsCatchType(); } void setF32AsCatchType() { - types.push_back({F32Type::typeName, F32Type::typeInfoRef, F32Type::catchableTypeInfoRef}); + types.push_back({F32Type::typeName, F32Type::typeInfoRef, F32Type::catchableTypeInfoRef, F32Type::catchableTypeSize}); catchableTypeInfoArrayRef = F32Type::catchableTypeInfoArrayRef; throwInfoRef = F32Type::throwInfoRef; @@ -63,7 +68,7 @@ class LLVMRTTIHelperVCWin32 void setF64AsCatchType() { - types.push_back({F64Type::typeName, F64Type::typeInfoRef, F64Type::catchableTypeInfoRef}); + types.push_back({F64Type::typeName, F64Type::typeInfoRef, F64Type::catchableTypeInfoRef, F64Type::catchableTypeSize}); catchableTypeInfoArrayRef = F64Type::catchableTypeInfoArrayRef; throwInfoRef = F64Type::throwInfoRef; @@ -71,7 +76,7 @@ class LLVMRTTIHelperVCWin32 void setI32AsCatchType() { - types.push_back({I32Type::typeName, I32Type::typeInfoRef, I32Type::catchableTypeInfoRef}); + types.push_back({I32Type::typeName, I32Type::typeInfoRef, I32Type::catchableTypeInfoRef, I32Type::catchableTypeSize}); catchableTypeInfoArrayRef = I32Type::catchableTypeInfoArrayRef; throwInfoRef = I32Type::throwInfoRef; @@ -79,8 +84,8 @@ class LLVMRTTIHelperVCWin32 void setStringTypeAsCatchType() { - types.push_back({StringType::typeName, StringType::typeInfoRef, StringType::catchableTypeInfoRef}); - types.push_back({StringType::typeName2, StringType::typeInfoRef2, StringType::catchableTypeInfoRef2}); + types.push_back({StringType::typeName, StringType::typeInfoRef, StringType::catchableTypeInfoRef, pointerSize()}); + types.push_back({StringType::typeName2, StringType::typeInfoRef2, StringType::catchableTypeInfoRef2, pointerSize()}); catchableTypeInfoArrayRef = StringType::catchableTypeInfoArrayRef; throwInfoRef = StringType::throwInfoRef; @@ -88,7 +93,7 @@ class LLVMRTTIHelperVCWin32 void setI8PtrAsCatchType() { - types.push_back({I8PtrType::typeName, I8PtrType::typeInfoRef, I8PtrType::catchableTypeInfoRef}); + types.push_back({I8PtrType::typeName, I8PtrType::typeInfoRef, I8PtrType::catchableTypeInfoRef, pointerSize()}); catchableTypeInfoArrayRef = I8PtrType::catchableTypeInfoArrayRef; throwInfoRef = I8PtrType::throwInfoRef; @@ -98,14 +103,21 @@ class LLVMRTTIHelperVCWin32 { types.push_back({join(name, ClassType::typeName, ClassType::typeNameSuffix), join(name, ClassType::typeInfoRef, ClassType::typeInfoRefSuffix), - join(name, ClassType::catchableTypeInfoRef, ClassType::catchableTypeInfoRefSuffix)}); + join(name, ClassType::catchableTypeInfoRef, ClassType::catchableTypeInfoRefSuffix), pointerSize()}); - types.push_back({ClassType::typeName2, ClassType::typeInfoRef2, ClassType::catchableTypeInfoRef2}); + types.push_back({ClassType::typeName2, ClassType::typeInfoRef2, ClassType::catchableTypeInfoRef2, pointerSize()}); catchableTypeInfoArrayRef = ClassType::catchableTypeInfoArrayRef; throwInfoRef = ClassType::throwInfoRef; } + // A pointer-shaped catchable type (a string, an opaque pointer, a class reference) is as + // wide as the target's pointer, unlike `int` and `double`, which are fixed. + int pointerSize() + { + return compileOptions.sizeBits / 8; + } + std::string join(StringRef name, const char *prefix, const char *suffix) { std::stringstream ss; @@ -280,7 +292,7 @@ class LLVMRTTIHelperVCWin32 { for (auto type : types) { - if (mlir::failed(catchableType(loc, type.catchableTypeInfoRef, type.typeInfoRef, type.typeName))) + if (mlir::failed(catchableType(loc, type.catchableTypeInfoRef, type.typeInfoRef, type.typeName, type.catchableTypeSize))) { return mlir::failure(); } @@ -289,7 +301,8 @@ class LLVMRTTIHelperVCWin32 return mlir::success(); } - LogicalResult catchableType(mlir::Location loc, StringRef catchableTypeInfoRefName, StringRef typeInfoRefName, StringRef typeName) + LogicalResult catchableType(mlir::Location loc, StringRef catchableTypeInfoRefName, StringRef typeInfoRefName, StringRef typeName, + int catchableTypeSize) { auto name = catchableTypeInfoRefName; if (parentModule.lookupSymbol(name)) @@ -339,7 +352,7 @@ class LLVMRTTIHelperVCWin32 auto itemValue5 = rewriter.create(loc, th.getI32Type(), rewriter.getI32IntegerAttr(0)); ch.setStructValue(loc, structVal, itemValue5, 4); - auto itemValue6 = rewriter.create(loc, th.getI32Type(), rewriter.getI32IntegerAttr(8)); + auto itemValue6 = rewriter.create(loc, th.getI32Type(), rewriter.getI32IntegerAttr(catchableTypeSize)); ch.setStructValue(loc, structVal, itemValue6, 5); auto itemValue7 = rewriter.create(loc, th.getI32Type(), rewriter.getI32IntegerAttr(0)); diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32Const.h b/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32Const.h index ca22e0240..289c1ad64 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32Const.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMRTTIHelperVCWin32Const.h @@ -7,6 +7,17 @@ namespace typescript namespace windows { +// NOTE: every mangled name below is 64-bit MSVC mangling - `PEA` is a `__ptr64` pointer, and the +// trailing digits of a `_CT...` name are the size of the value a catch copies. A 32-bit target +// needs its own table (`PA`, and `@84` for pointers), not just a different size; nothing here +// adapts on its own. +// +// `catchableTypeSize` is what goes in CatchableType::sizeOrOffset, and it is load-bearing: the +// CRT copies exactly that many bytes into the catch variable's frame slot, so a size that is too +// large overwrites whatever the frame put above that slot. It must agree with the digits in +// `catchableTypeInfoRef`. Pointer-shaped types take the target's pointer size instead of a +// constant here, so they are not listed. + constexpr const auto *typeInfoExtRef = "??_7type_info@@6B@"; constexpr const auto *imageBaseRef = "__ImageBase"; @@ -17,6 +28,8 @@ constexpr const auto *typeInfoRef = "??_R0N@8"; constexpr const auto *catchableTypeInfoRef = "_CT??_R0N@88"; constexpr const auto *catchableTypeInfoArrayRef = "_CTA1N"; constexpr const auto *throwInfoRef = "_TI1N"; +// describes `.N` (double), like F64Type - see setF32AsCatchType +constexpr int catchableTypeSize = 8; } // namespace F32Type namespace F64Type @@ -26,6 +39,7 @@ constexpr const auto *typeInfoRef = "??_R0N@8"; constexpr const auto *catchableTypeInfoRef = "_CT??_R0N@88"; constexpr const auto *catchableTypeInfoArrayRef = "_CTA1N"; constexpr const auto *throwInfoRef = "_TI1N"; +constexpr int catchableTypeSize = 8; } // namespace F64Type namespace I32Type @@ -35,6 +49,9 @@ constexpr const auto *typeInfoRef = "??_R0H@8"; constexpr const auto *catchableTypeInfoRef = "_CT??_R0H@84"; constexpr const auto *catchableTypeInfoArrayRef = "_CTA1H"; constexpr const auto *throwInfoRef = "_TI1H"; +// 4, not the pointer size: `int` is 4 bytes on every target, and the `4` at the end of +// `_CT??_R0H@84` says so too +constexpr int catchableTypeSize = 4; } // namespace I32Type namespace StringType diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h new file mode 100644 index 000000000..759c27b11 --- /dev/null +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -0,0 +1,1249 @@ +#ifndef MLIR_TYPESCRIPT_LOWERTOLLVMLOGIC_OWNERSHIPROUTINELOGIC_H_ +#define MLIR_TYPESCRIPT_LOWERTOLLVMLOGIC_OWNERSHIPROUTINELOGIC_H_ + +#include "TypeScript/Config.h" +#include "TypeScript/Defines.h" +#include "TypeScript/TypeScriptOps.h" + +#include "TypeScript/MLIRLogic/MLIRTypeHelper.h" + +#include "TypeScript/LowerToLLVM/TypeHelper.h" +#include "TypeScript/LowerToLLVM/TypeConverterHelper.h" +#include "TypeScript/LowerToLLVM/CodeLogicHelper.h" +#include "TypeScript/LowerToLLVM/LLVMCodeHelperBase.h" +#include "TypeScript/LowerToLLVM/TypeDescriptorLogic.h" + +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" + +using namespace mlir; +namespace mlir_ts = mlir::typescript; + +namespace typescript +{ + +// Generates, once per type, the routine that drops one reference to a value of that type: +// each heap block it owns loses a reference, and the ones that lose their last are destroyed - +// their fields released in turn, then freed. The routine's address goes in the type's +// descriptor (TYPE_DESCR_RELEASE), which is the only thing that references it -- nothing calls +// these yet. See docs/reference-counting-evaluation.md sections 9.4 and 9.6. +// +// The routines are reference-counting shaped in every memory model, because they are dead code +// in all but `-mm=rc`, and one shape is simpler than two. Only `-mm=rc` initialises the count +// they read (LLVMCodeHelperBase::_MemoryAlloc). +// +// Calling convention: the routine takes a pointer to the *storage holding* a value of the +// type, not the value. That is uniform across value categories - a class field, an "any" +// payload slot and a local variable are all addressed the same way - and it is what lets a +// field's release be a plain call with a GEP. +class OwnershipRoutineLogic +{ + Operation *op; + PatternRewriter &rewriter; + const TypeConverter *typeConverter; + CompileOptions &compileOptions; + + public: + OwnershipRoutineLogic(Operation *op, PatternRewriter &rewriter, const TypeConverter *typeConverter, + CompileOptions &compileOptions) + : op(op), rewriter(rewriter), typeConverter(typeConverter), compileOptions(compileOptions) + { + } + + // Symbol name of the routine for `type`, generating it if needed. Empty when the type + // owns no heap memory, in which case the descriptor's release slot stays null - a null + // slot means "nothing to release", not "unknown". + // A generated ownership routine belongs to no user function, so it must not carry that + // function's DISubprogram - but it must still carry a location. + // + // Every op in one of these routines is built with `op->getLoc()`, the location of whatever + // op triggered the generation, because that is the only location in scope. Under `--di` that + // is a FusedLoc carrying the *enclosing* function's DISubprogram, and the translation + // attaches it to whatever function it lands on - so `main`'s subprogram was attached to + // `tsrel_...`, `tsret_...`, `__tslang_inc_ref` and `__tslang_free_block` as well, and + // "DISubprogram attached to more than one function" failed the module. No reference-counted + // program could be built with debug info at all, in any tier. + // + // Dropping the location entirely does not work, and the way it fails is worth recording: + // `DIScopeForLLVMFuncOpPass` then *gives* the routine a fresh subprogram of its own, and its + // body ops - now at unknown locations - trip the opposite check, "inlinable function call in + // a function with a DISubprogram location must have a debug location". + // + // So the subprogram is peeled off and the file/line underneath it kept. The routine reaches + // that pass with no subprogram and a real location, gets one of its own, and every op in it + // still has somewhere to hang. Section 9.69. + // Reduces a location to its plain file/line, discarding every layer of debug scope wrapped + // around it - the fused DISubprogram, a fused DILocalVariable, a call-site chain. + // + // Peeling only the subprogram is not enough, and how that fails is worth recording: an op + // whose location is fused with a DILocalVariable still names a scope inside the *user* + // function, so the routine gets "!dbg attachment points at wrong subprogram for function" + // instead. These routines have no source variables and no call sites of their own, so + // there is nothing here worth keeping above the file and line. + static mlir::Location plainLocation(mlir::Location loc) + { + if (auto fused = mlir::dyn_cast(loc)) + { + auto nested = fused.getLocations(); + return nested.empty() ? mlir::UnknownLoc::get(loc.getContext()) : plainLocation(nested.front()); + } + + if (auto callSite = mlir::dyn_cast(loc)) + { + return plainLocation(callSite.getCallee()); + } + + // A NameLoc wraps the location it names rather than replacing it, so a scope can hide + // one layer further in: `loc("sAny"(fused<#di_subprogram
>[...]))` is what a block + // argument of a generated routine carries, and unwrapping only the fused and call-site + // forms walks straight past it. + if (auto named = mlir::dyn_cast(loc)) + { + return plainLocation(named.getChildLoc()); + } + + return loc; + } + + static void dropDebugLocations(mlir::LLVM::LLVMFuncOp funcOp) + { + funcOp->setLoc(plainLocation(funcOp->getLoc())); + funcOp->walk([](mlir::Operation *nested) { nested->setLoc(plainLocation(nested->getLoc())); }); + + // Block arguments carry locations of their own, and walking operations does not reach + // them. They become phis, so leaving them alone leaves the routine with + // `%11 = phi i64 ..., !dbg !39` naming the user function's scope - the same "wrong + // subprogram" complaint, arriving from the one place the operation walk cannot see. + funcOp->walk([](mlir::Block *block) { + for (auto arg : block->getArguments()) + { + arg.setLoc(plainLocation(arg.getLoc())); + } + }); + } + + std::string getOrCreateReleaseRoutine(mlir::Type type) + { + if (!ownsHeapMemory(type)) + { + return {}; + } + + auto name = getRoutineName(type); + auto parentModule = op->getParentOfType(); + if (parentModule.lookupSymbol(name)) + { + return name; + } + + TypeHelper th(rewriter); + auto loc = op->getLoc(); + + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto funcOp = rewriter.create( + loc, name, th.getFunctionType(th.getVoidType(), {th.getPtrType()}), LLVM::Linkage::Internal); + + // the symbol must exist before the body is built: a recursive type (`class Node { + // next: Node }`) reaches its own routine while generating it + auto *entryBlock = funcOp.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + buildBody(type, entryBlock->getArgument(0)); + + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(funcOp); + return name; + } + + // Symbol name of the retain routine for `type`, generating it if needed. Empty when the + // type owns no heap memory, in which case the descriptor's retain slot stays null. + // + // Like release, the routine addresses the storage holding a value rather than the value. + std::string getOrCreateRetainRoutine(mlir::Type type) + { + if (!ownsHeapMemory(type)) + { + return {}; + } + + auto name = getRetainRoutineName(type); + auto parentModule = op->getParentOfType(); + if (parentModule.lookupSymbol(name)) + { + return name; + } + + TypeHelper th(rewriter); + auto loc = op->getLoc(); + + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto funcOp = rewriter.create( + loc, name, th.getFunctionType(th.getVoidType(), {th.getPtrType()}), LLVM::Linkage::Internal); + + // as with release, the symbol must exist before the body is built, so that a + // recursive type reaches its own routine while generating it + auto *entryBlock = funcOp.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + buildRetainBody(type, entryBlock->getArgument(0)); + + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(funcOp); + return name; + } + + // Takes one reference to `value`, whose TypeScript type is `type`. Emits nothing when the + // type owns no heap memory. Wrapped for the same reason as emitReleaseValue. + void emitRetainValue(mlir::Type type, mlir::Value value) + { + auto wrapperName = getOrCreateRetainValueRoutine(type); + if (wrapperName.empty()) + { + return; + } + + rewriter.create(op->getLoc(), TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), wrapperName), ValueRange{value}); + } + + // Drops one reference held by `value`, whose TypeScript type is `type`. Emits nothing + // when the type owns no heap memory. + // + // The per-type routines address storage rather than values, so this goes through a small + // value-taking wrapper. Its alloca sits in the wrapper's own entry block, which keeps + // every caller from having to find a safe place for one - a release inside a loop must not + // grow the frame - and LLVM inlines and promotes the whole thing away. + void emitReleaseValue(mlir::Type type, mlir::Value value) + { + auto wrapperName = getOrCreateReleaseValueRoutine(type); + if (wrapperName.empty()) + { + return; + } + + rewriter.create(op->getLoc(), TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), wrapperName), ValueRange{value}); + } + + // Drops one reference held by the value in `slotPtr`, whose TypeScript type is `type`. + // Emits nothing when the type owns no heap memory. + // + // The slot-taking form is what `ts.ReleaseSlot` lowers to: MLIRGen already has the + // variable's storage in hand, so going through emitReleaseValue's alloca wrapper would + // only spill a value that was already in memory. + void emitReleaseSlot(mlir::Type type, mlir::Value slotPtr) + { + releaseSlot(type, slotPtr); + } + + // Takes one reference on the value in `slotPtr`. The mirror of emitReleaseSlot. + void emitRetainSlot(mlir::Type type, mlir::Value slotPtr) + { + retainSlot(type, slotPtr); + } + + // === Capture cells === + // + // A variable captured by reference does not live in its frame: its storage is a heap block + // of its own, so that the frame and every closure over it read and write the same value. + // `cellPtr` addresses that block, and these two count owners *of the block* - which is a + // different question from emitRetainSlot/emitReleaseSlot, who count owners of the value in + // it. See docs/reference-counting-evaluation.md section 9.34. + + void emitRetainCell(mlir::Value cellPtr) + { + emitIncRef(cellPtr); + } + + // The value goes back before the block does, and in that order: releasing it reads the + // cell. + void emitReleaseCell(mlir::Type contentsType, mlir::Value cellPtr) + { + emitIfLastReference(cellPtr, [&]() { + releaseSlot(contentsType, cellPtr); + emitFreeBlock(cellPtr); + }); + } + + // === Capture boxes === + // + // A closure's `this` is a heap block with one field per captured variable, and it owns two + // different things at once: the cell of each variable captured by reference, and whatever + // the inline copy of each variable captured by value owns. The generic record routines + // handle only the second - a RefType field is storage a value does not own, everywhere + // else in the compiler - so a capture box gets routines of its own. + // + // They are keyed by the capture's own `ref>` type rather than by the box's + // `object>`, so that the descriptor and the routines cannot collide with the + // generic ones for an object of the same shape. + std::string getOrCreateCaptureBoxReleaseRoutine(mlir_ts::RefType captureRefType) + { + return buildCaptureBoxRoutine(captureRefType, "tsrelcb_", /*retaining=*/false); + } + + // Copying a closure duplicates its one reference to the box and nothing else - what the + // box holds is not duplicated - so this stops at the block, as an object's retain does. + std::string getOrCreateCaptureBoxRetainRoutine(mlir_ts::RefType captureRefType) + { + return buildCaptureBoxRoutine(captureRefType, "tsretcb_", /*retaining=*/true); + } + + // Does a value of this type own heap memory, directly or through its fields? The same + // question decides both directions: a type with nothing to release has nothing to retain. + // + // The answer lives in MLIRTypeHelper because MLIRGen has to ask it too - it is what + // decides whether a local is an owner - and the two sides disagreeing would place retains + // that never pair with a release. + bool ownsHeapMemory(mlir::Type type) + { + MLIRTypeHelper mth(rewriter.getContext(), compileOptions); + return mth.ownsHeapMemory(op->getLoc(), type); + } + + // Gives back the references held by `count` elements starting at `startIndex`, in an array + // whose data begins at `dataPtr`. + // + // `splice` is what needs this: the elements it removes are memmoved over, so the references + // in those slots are dropped on the floor rather than released. Every other insertion point + // in this arc sits in MLIRGen, where the number of elements involved is a compile-time + // matter; here it is a runtime value known only at this level, which is why this one release + // is emitted from the lowering. See §9.74. + // + // Emitted **only under `-mm=rc`**, and that check cannot be skipped. The release routines are + // reference-counting shaped in every memory model and are dead weight under `gc` (§9.4); what + // keeps them dead there is that `ts.Release` erases on the way to LLVM (§9.10). A direct call + // planted by a lowering has no such eraser in front of it, so without this guard `gc` would + // start freeing objects it is still tracing. + void emitReleaseArrayElements(mlir::Type elementType, mlir::Value dataPtr, mlir::Value startIndex, + mlir::Value count) + { + if (!compileOptions.isRefCounted()) + { + return; + } + + auto routineName = getOrCreateReleaseRoutine(elementType); + if (routineName.empty()) + { + return; + } + + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + auto llvmElementType = tch.convertType(elementType); + + emitCountedLoop(count, [&](mlir::Value index) { + auto offset = rewriter.create(loc, index.getType(), index, startIndex); + auto elementPtr = + rewriter.create(loc, ptrTy, llvmElementType, dataPtr, ValueRange{offset}); + rewriter.create(loc, TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), routineName), + ValueRange{elementPtr}); + }); + } + + private: + // Field types of a record-shaped type, empty for anything else. + llvm::SmallVector getFieldTypes(mlir::Type type) + { + MLIRTypeHelper mth(rewriter.getContext(), compileOptions); + return mth.getOwnershipFieldTypes(type); + } + + std::string getOrCreateReleaseValueRoutine(mlir::Type type) + { + return getOrCreateValueWrapper(type, getOrCreateReleaseRoutine(type), "tsrelv_"); + } + + std::string getRoutineName(mlir::Type type) + { + std::stringstream ss; + ss << "tsrel_" << (size_t)hash_value(type); + return ss.str(); + } + + std::string getRetainRoutineName(mlir::Type type) + { + std::stringstream ss; + ss << "tsret_" << (size_t)hash_value(type); + return ss.str(); + } + + std::string getOrCreateRetainValueRoutine(mlir::Type type) + { + return getOrCreateValueWrapper(type, getOrCreateRetainRoutine(type), "tsretv_"); + } + + // A value-taking wrapper around a storage-taking routine: stores the value into an alloca + // in the wrapper's own entry block and calls through. Keeping the alloca here rather than + // at each call site means a retain or release inside a loop never grows the caller's + // frame, and LLVM inlines and promotes the whole thing away. + std::string getOrCreateValueWrapper(mlir::Type type, std::string slotRoutine, StringRef prefix) + { + if (slotRoutine.empty()) + { + return {}; + } + + std::stringstream nameStream; + nameStream << prefix.str() << (size_t)hash_value(type); + auto name = nameStream.str(); + + auto parentModule = op->getParentOfType(); + if (parentModule.lookupSymbol(name)) + { + return name; + } + + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + CodeLogicHelper clh(op, rewriter); + + auto loc = op->getLoc(); + auto llvmType = tch.convertType(type); + + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto funcOp = rewriter.create(loc, name, th.getFunctionType(th.getVoidType(), {llvmType}), + LLVM::Linkage::Internal); + + auto *entryBlock = funcOp.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + auto slot = rewriter.create(loc, th.getPtrType(), llvmType, clh.createI32ConstantOf(1)); + rewriter.create(loc, entryBlock->getArgument(0), slot); + rewriter.create(loc, TypeRange{}, FlatSymbolRefAttr::get(rewriter.getContext(), slotRoutine), + ValueRange{slot}); + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(funcOp); + return name; + } + + // free(payload - headerSize). One generated helper rather than an inline free at every + // site, so there is a single place for the allocator to change under `-mm=rc`. + // + // Only ever reached from inside emitIfLastReference, so the block is known to be mortal + // and to have just lost its last reference. + void emitFreeBlock(mlir::Value payloadPtr) + { + TypeHelper th(rewriter); + auto loc = op->getLoc(); + auto parentModule = op->getParentOfType(); + + const char *helperName = "__tslang_free_block"; + if (!parentModule.lookupSymbol(helperName)) + { + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto helper = rewriter.create( + loc, helperName, th.getFunctionType(th.getVoidType(), {th.getPtrType()}), LLVM::Linkage::Internal); + + auto *entryBlock = helper.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + LLVMCodeHelperBase ch(op, rewriter, typeConverter, compileOptions); + ch.MemoryFree(entryBlock->getArgument(0)); + + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(helper); + } + + rewriter.create(loc, TypeRange{}, FlatSymbolRefAttr::get(rewriter.getContext(), helperName), + ValueRange{payloadPtr}); + } + + // Drops one reference to a block, answering "was that the last one?" - that is, should the + // caller now destroy the value and free the block. + // + // A block marked HEAP_BLOCK_IMMORTAL is neither decremented nor ever the last. That is what + // lets a string literal, or a `typeof` result pointing into a descriptor, be released like + // any other string without writing to read-only memory or freeing a static block. + mlir::Value emitDecRef(mlir::Value payloadPtr) + { + TypeHelper th(rewriter); + auto loc = op->getLoc(); + auto parentModule = op->getParentOfType(); + + const char *helperName = "__tslang_dec_ref"; + if (!parentModule.lookupSymbol(helperName)) + { + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto helper = rewriter.create( + loc, helperName, th.getFunctionType(th.getLLVMBoolType(), {th.getPtrType()}), LLVM::Linkage::Internal); + + auto *entryBlock = helper.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + TypeConverterHelper tch(typeConverter); + LLVMCodeHelperBase ch(op, rewriter, typeConverter, compileOptions); + + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, entryBlock->getArgument(0), llvmIndexType); + auto count = rewriter.create(loc, llvmIndexType, blockPtr); + auto immortal = rewriter.create( + loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)); + auto isMortal = rewriter.create(loc, LLVM::ICmpPredicate::ne, count, immortal); + + auto *decBlock = rewriter.createBlock(&helper.getBody(), helper.getBody().end()); + auto *immortalBlock = rewriter.createBlock(&helper.getBody(), helper.getBody().end()); + + rewriter.setInsertionPointToEnd(entryBlock); + rewriter.create(loc, isMortal, decBlock, immortalBlock); + + rewriter.setInsertionPointToStart(decBlock); + auto one = rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1)); + auto newCount = rewriter.create(loc, llvmIndexType, count, one); + rewriter.create(loc, newCount, blockPtr); + auto zero = rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 0)); + auto wasLast = rewriter.create(loc, LLVM::ICmpPredicate::eq, newCount, zero); + rewriter.create(loc, ValueRange{wasLast}); + + rewriter.setInsertionPointToStart(immortalBlock); + rewriter.create( + loc, ValueRange{rewriter.create(loc, th.getLLVMBoolType(), + rewriter.getIntegerAttr(th.getLLVMBoolType(), 0))}); + + dropDebugLocations(helper); + } + + auto callOp = rewriter.create(loc, TypeRange{th.getLLVMBoolType()}, + FlatSymbolRefAttr::get(rewriter.getContext(), helperName), + ValueRange{payloadPtr}); + return callOp.getResult(); + } + + // Takes one more reference to a block, when there is a block and it is mortal. + // + // Skipping an immortal block is not an optimisation: incrementing HEAP_BLOCK_IMMORTAL + // would turn all-ones into zero, and the next release would read that as "last reference" + // and free a string literal. + void emitIncRef(mlir::Value payloadPtr) + { + TypeHelper th(rewriter); + auto loc = op->getLoc(); + auto parentModule = op->getParentOfType(); + + const char *helperName = "__tslang_inc_ref"; + if (!parentModule.lookupSymbol(helperName)) + { + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto helper = rewriter.create( + loc, helperName, th.getFunctionType(th.getVoidType(), {th.getPtrType()}), LLVM::Linkage::Internal); + + auto *entryBlock = helper.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + TypeConverterHelper tch(typeConverter); + LLVMCodeHelperBase ch(op, rewriter, typeConverter, compileOptions); + + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto payload = entryBlock->getArgument(0); + + auto nullPtr = rewriter.create(loc, th.getPtrType()); + auto isNotNull = rewriter.create(loc, LLVM::ICmpPredicate::ne, payload, nullPtr); + + auto *loadBlock = rewriter.createBlock(&helper.getBody(), helper.getBody().end()); + auto *incBlock = rewriter.createBlock(&helper.getBody(), helper.getBody().end()); + auto *returnBlock = rewriter.createBlock(&helper.getBody(), helper.getBody().end()); + + rewriter.setInsertionPointToEnd(entryBlock); + rewriter.create(loc, isNotNull, loadBlock, returnBlock); + + rewriter.setInsertionPointToStart(loadBlock); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, payload, llvmIndexType); + auto count = rewriter.create(loc, llvmIndexType, blockPtr); + auto immortal = rewriter.create( + loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)); + auto isMortal = rewriter.create(loc, LLVM::ICmpPredicate::ne, count, immortal); + rewriter.create(loc, isMortal, incBlock, returnBlock); + + rewriter.setInsertionPointToStart(incBlock); + auto one = rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1)); + rewriter.create(loc, rewriter.create(loc, llvmIndexType, count, one), blockPtr); + rewriter.create(loc, ValueRange{}, returnBlock); + + rewriter.setInsertionPointToStart(returnBlock); + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(helper); + } + + rewriter.create(loc, TypeRange{}, FlatSymbolRefAttr::get(rewriter.getContext(), helperName), + ValueRange{payloadPtr}); + } + + // Runs `thenBody` -- the destroy half: release what the value owns, then free it -- only + // when `payloadPtr` is non-null and the reference being dropped was the last one. + void emitIfLastReference(mlir::Value payloadPtr, llvm::function_ref thenBody) + { + TypeHelper th(rewriter); + auto loc = op->getLoc(); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + auto *thenBlock = rewriter.createBlock(continuationBlock); + auto *decBlock = rewriter.createBlock(thenBlock); + + rewriter.setInsertionPointToEnd(thenBlock); + thenBody(); + rewriter.create(loc, ValueRange{}, continuationBlock); + + rewriter.setInsertionPointToEnd(decBlock); + auto wasLast = emitDecRef(payloadPtr); + rewriter.create(loc, wasLast, thenBlock, continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + auto nullPtr = rewriter.create(loc, th.getPtrType()); + auto isNotNull = rewriter.create(loc, LLVM::ICmpPredicate::ne, payloadPtr, nullPtr); + rewriter.create(loc, isNotNull, decBlock, continuationBlock); + + rewriter.setInsertionPointToStart(continuationBlock); + } + + // Runs `thenBody` only when `ptrValue` is not null, and leaves the insertion point on the + // continuation. + void emitIfNonNull(mlir::Value ptrValue, llvm::function_ref thenBody) + { + TypeHelper th(rewriter); + auto loc = op->getLoc(); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + auto *thenBlock = rewriter.createBlock(continuationBlock); + + rewriter.setInsertionPointToEnd(thenBlock); + thenBody(); + rewriter.create(loc, ValueRange{}, continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + auto nullPtr = rewriter.create(loc, th.getPtrType()); + auto isNotNull = rewriter.create(loc, LLVM::ICmpPredicate::ne, ptrValue, nullPtr); + rewriter.create(loc, isNotNull, thenBlock, continuationBlock); + + rewriter.setInsertionPointToStart(continuationBlock); + } + + // Calls the release routine of `type` on `slotPtr`, if it has one. + void releaseSlot(mlir::Type type, mlir::Value slotPtr) + { + auto routineName = getOrCreateReleaseRoutine(type); + if (routineName.empty()) + { + return; + } + + rewriter.create(op->getLoc(), TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), routineName), ValueRange{slotPtr}); + } + + // Releases each field a record-shaped value owns. `basePtr` addresses the record itself. + void releaseFields(mlir::Type recordType, mlir::Value basePtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto llvmRecordType = tch.convertType(recordType); + + for (auto [index, fieldType] : llvm::enumerate(getFieldTypes(recordType))) + { + // A field holding a `ref` to a tuple is a capture box - the `.captured` field of an + // object literal with methods, or of the state object a generator becomes - and it is + // the one field an object owns that the generic routines cannot see, because a + // reference into storage is not ownership anywhere else in the compiler. It gets the + // same treatment a closure's `this` gets (§9.33): give back the cells it holds, then + // free the box. Without it the box and every cell under it outlive the object that was + // their only owner, which is a generator's parameter leaking once per call (§9.52). + // + // Nothing else produces a `ref>` field: `ref` is not spellable in the + // language, so this shape is the compiler's own and always means a capture box. + auto routineName = std::string(); + if (auto refFieldType = dyn_cast(fieldType)) + { + if (isa(refFieldType.getElementType())) + { + routineName = getOrCreateCaptureBoxReleaseRoutine(refFieldType); + } + } + else + { + routineName = getOrCreateReleaseRoutine(fieldType); + } + + if (routineName.empty()) + { + continue; + } + + auto fieldPtr = rewriter.create(loc, th.getPtrType(), llvmRecordType, basePtr, + ArrayRef{0, (int32_t)index}); + rewriter.create(loc, TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), routineName), + ValueRange{fieldPtr}); + } + } + + // Reads the release routine out of the descriptor `tagValue` names and calls it on + // `valueSlotPtr`, when the descriptor has one. This is the payoff of tags pointing into + // descriptors: a value whose type is only known at run time can still be released. + void releaseViaDescriptor(mlir::Value tagValue, mlir::Value valueSlotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + TypeDescriptorLogic tdl(rewriter, tch, op->getLoc()); + + auto loc = op->getLoc(); + + auto recordPtr = tdl.getRecordPtrFromTag(tagValue); + auto releasePtrSlot = + rewriter.create(loc, th.getPtrType(), + TypeDescriptorLogic::getRecordType(rewriter, tch.convertType(th.getIndexType())), + recordPtr, ArrayRef{0, TYPE_DESCR_RELEASE}); + auto releaseFn = rewriter.create(loc, th.getPtrType(), releasePtrSlot); + + emitIfNonNull(releaseFn, [&]() { + // indirect call: callee pointer is operand #0, the rest are call arguments, and + // AttrSizedOperandSegments needs that split set explicitly + mlir::SmallVector ops{releaseFn, valueSlotPtr}; + auto callOp = rewriter.create(loc, TypeRange{}, ops); + callOp.getProperties().setOperandSegmentSizes({static_cast(ops.size()), 0}); + callOp.setOpBundleSizes({}); + }); + } + + // Both directions for a value shaped { .., this, type } - an interface, and a bound or + // hybrid function. They differ only in which descriptor slot they read: load the tag beside + // `this`, and hand the address of the `this` field to the concrete type's own routine. That + // address is what the routine wants either way - a release or retain routine takes the + // storage holding a value, and the second field is exactly the storage holding the class, + // object or capture-box reference. + // + // The tag is checked before anything reads through it: getRecordPtrFromTag walks backwards + // from the tag to the record, so a null tag would be dereferenced, not skipped, by the + // null check inside releaseViaDescriptor. A null tag is the ordinary case for a function + // value that is not a closure, so this is not a corner. + void releaseViaTagBesideThis(mlir::Type type, mlir::Value slotPtr, int32_t tagIndex, bool retaining) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + auto llvmType = tch.convertType(type); + + auto tagSlot = rewriter.create(loc, ptrTy, llvmType, slotPtr, + ArrayRef{0, tagIndex}); + auto tagValue = rewriter.create(loc, ptrTy, tagSlot); + auto thisSlot = rewriter.create(loc, ptrTy, llvmType, slotPtr, + ArrayRef{0, THIS_VALUE_INDEX}); + + emitIfNonNull(tagValue, [&]() { + if (retaining) + { + retainViaDescriptor(tagValue, thisSlot); + } + else + { + releaseViaDescriptor(tagValue, thisSlot); + } + }); + } + + void releaseViaInterfaceTag(mlir::Type type, mlir::Value slotPtr, bool retaining) + { + releaseViaTagBesideThis(type, slotPtr, INTERFACE_TYPE_INDEX, retaining); + } + + // Body of both capture-box routines: they take the storage holding the box pointer, like + // every other routine, so each begins by loading the box out of it. + std::string buildCaptureBoxRoutine(mlir_ts::RefType captureRefType, StringRef prefix, bool retaining) + { + std::stringstream nameStream; + nameStream << prefix.str() << (size_t)hash_value(captureRefType); + auto name = nameStream.str(); + + auto parentModule = op->getParentOfType(); + if (parentModule.lookupSymbol(name)) + { + return name; + } + + TypeHelper th(rewriter); + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto funcOp = rewriter.create( + loc, name, th.getFunctionType(th.getVoidType(), {ptrTy}), LLVM::Linkage::Internal); + + auto *entryBlock = funcOp.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + auto boxValue = rewriter.create(loc, ptrTy, entryBlock->getArgument(0)); + if (retaining) + { + emitIncRef(boxValue); + } + else + { + emitIfLastReference(boxValue, [&]() { + releaseCapturedFields(captureRefType.getElementType(), boxValue); + emitFreeBlock(boxValue); + }); + } + + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(funcOp); + return name; + } + + // Gives back what a dying box holds: one owner of each captured cell, and the contents of + // each field captured by value. + void releaseCapturedFields(mlir::Type tupleType, mlir::Value boxPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + auto llvmTupleType = tch.convertType(tupleType); + + for (auto [index, fieldType] : llvm::enumerate(getFieldTypes(tupleType))) + { + auto refFieldType = dyn_cast(fieldType); + if (!refFieldType && !ownsHeapMemory(fieldType)) + { + continue; + } + + auto fieldPtr = rewriter.create(loc, ptrTy, llvmTupleType, boxPtr, + ArrayRef{0, (int32_t)index}); + if (refFieldType) + { + // the field holds the cell's address, so the cell is one load further in + emitReleaseCell(refFieldType.getElementType(), rewriter.create(loc, ptrTy, fieldPtr)); + } + else + { + releaseSlot(fieldType, fieldPtr); + } + } + } + + void buildBody(mlir::Type type, mlir::Value slotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + + // a string is its own block + if (isa(type)) + { + auto strValue = rewriter.create(loc, ptrTy, slotPtr); + emitIfLastReference(strValue, [&]() { emitFreeBlock(strValue); }); + return; + } + + // an array value is { data, length }; it owns the data block and, through it, the + // elements + if (auto arrayType = dyn_cast(type)) + { + buildArrayBody(arrayType, slotPtr); + return; + } + + // a class or object reference owns the instance block + if (isa(type) || isa(type)) + { + auto storageType = isa(type) ? cast(type).getStorageType() + : cast(type).getStorageType(); + + auto instanceValue = rewriter.create(loc, ptrTy, slotPtr); + emitIfLastReference(instanceValue, [&]() { + releaseFields(storageType, instanceValue); + emitFreeBlock(instanceValue); + }); + return; + } + + // an "any" box owns its own block, and its payload's type is only known through the + // tag + if (isa(type)) + { + auto boxValue = rewriter.create(loc, ptrTy, slotPtr); + emitIfLastReference(boxValue, [&]() { + auto anyStructType = LLVM::LLVMStructType::getLiteral( + rewriter.getContext(), {tch.convertType(th.getIndexType()), ptrTy, th.getI8Type()}, false); + + auto tagSlot = rewriter.create(loc, ptrTy, anyStructType, boxValue, + ArrayRef{0, ANY_TYPE}); + auto tagValue = rewriter.create(loc, ptrTy, tagSlot); + auto dataSlot = rewriter.create(loc, ptrTy, anyStructType, boxValue, + ArrayRef{0, ANY_DATA}); + + releaseViaDescriptor(tagValue, dataSlot); + emitFreeBlock(boxValue); + }); + return; + } + + // an interface is { vtable, this, type }: the reference it holds is `this`, and what + // that points at is only known through the tag beside it. There is no block of the + // interface's own to free - the value is a pair of pointers held wherever it sits. + if (isa(type)) + { + releaseViaInterfaceTag(type, slotPtr, /*retaining=*/false); + return; + } + + // a closure is { func, this, type } and owns its `this` when that `this` is a capture + // box - which is what the tag says, and says nothing where it is a bound method + if (isa(type) || isa(type)) + { + releaseViaTagBesideThis(type, slotPtr, CLOSURE_TYPE_INDEX, /*retaining=*/false); + return; + } + + // a tagged union carries its payload inline, so there is no block of its own to + // free - only the payload to release, again through the tag + if (auto unionType = dyn_cast(type)) + { + MLIRTypeHelper mth(rewriter.getContext(), compileOptions); + mlir::Type baseType; + if (mth.isUnionTypeNeedsTag(loc, unionType, baseType)) + { + auto llvmUnionType = tch.convertType(unionType); + auto tagSlot = rewriter.create(loc, ptrTy, llvmUnionType, slotPtr, + ArrayRef{0, UNION_TAG_INDEX}); + auto tagValue = rewriter.create(loc, ptrTy, tagSlot); + auto valueSlot = rewriter.create(loc, ptrTy, llvmUnionType, slotPtr, + ArrayRef{0, UNION_VALUE_INDEX}); + + // A null tag is a union that holds nothing yet, and that is the ordinary case + // rather than a corner: a class instance comes out of calloc zeroed, and the + // first assignment to a union field releases the old value before storing the + // new one. The check has to be here, because getRecordPtrFromTag walks + // backwards from the tag to find the descriptor - it would read through the + // null long before the null check inside releaseViaDescriptor. + emitIfNonNull(tagValue, [&]() { releaseViaDescriptor(tagValue, valueSlot); }); + } + else + { + releaseSlot(baseType, slotPtr); + } + + return; + } + + // an optional carries its value inline behind a flag + if (auto optionalType = dyn_cast(type)) + { + buildOptionalBody(optionalType, slotPtr); + return; + } + + // everything left is record-shaped and inline: release what the fields own, free + // nothing, because this value's storage belongs to whoever holds it + releaseFields(type, slotPtr); + } + + // Calls the retain routine of `type` on `slotPtr`, if it has one. + void retainSlot(mlir::Type type, mlir::Value slotPtr) + { + auto routineName = getOrCreateRetainRoutine(type); + if (routineName.empty()) + { + return; + } + + rewriter.create(op->getLoc(), TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), routineName), ValueRange{slotPtr}); + } + + // Retains each field an inline record-shaped value owns. `basePtr` addresses the record. + void retainFields(mlir::Type recordType, mlir::Value basePtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto llvmRecordType = tch.convertType(recordType); + + for (auto [index, fieldType] : llvm::enumerate(getFieldTypes(recordType))) + { + auto routineName = getOrCreateRetainRoutine(fieldType); + if (routineName.empty()) + { + continue; + } + + auto fieldPtr = rewriter.create(loc, th.getPtrType(), llvmRecordType, basePtr, + ArrayRef{0, (int32_t)index}); + rewriter.create(loc, TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), routineName), + ValueRange{fieldPtr}); + } + } + + // The retain counterpart of releaseViaDescriptor, reading the descriptor's retain slot. + void retainViaDescriptor(mlir::Value tagValue, mlir::Value valueSlotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + TypeDescriptorLogic tdl(rewriter, tch, op->getLoc()); + + auto loc = op->getLoc(); + + auto recordPtr = tdl.getRecordPtrFromTag(tagValue); + auto retainPtrSlot = + rewriter.create(loc, th.getPtrType(), + TypeDescriptorLogic::getRecordType(rewriter, tch.convertType(th.getIndexType())), + recordPtr, ArrayRef{0, TYPE_DESCR_RETAIN}); + auto retainFn = rewriter.create(loc, th.getPtrType(), retainPtrSlot); + + emitIfNonNull(retainFn, [&]() { + mlir::SmallVector ops{retainFn, valueSlotPtr}; + auto callOp = rewriter.create(loc, TypeRange{}, ops); + callOp.getProperties().setOperandSegmentSizes({static_cast(ops.size()), 0}); + callOp.setOpBundleSizes({}); + }); + } + + // The mirror of buildBody, and it is shorter for one reason worth stating plainly: + // retaining a *reference* stops at the block. Release recurses into an object's fields, + // but only inside emitIfLastReference - that is, only when the block is about to die and + // its fields' references die with it. A second reference to the same object does not + // duplicate the object's own references to its fields, so retain must not touch them. + // Only values held *inline* - tuples, optionals, tagged unions - propagate a retain + // inwards, because copying one really does duplicate every reference it holds. + void buildRetainBody(mlir::Type type, mlir::Value slotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + + // each of these is a reference to a block of its own: string, class and object + // instances, and an "any" box + if (isa(type) || isa(type) || isa(type) || + isa(type)) + { + emitIncRef(rewriter.create(loc, ptrTy, slotPtr)); + return; + } + + // an array value is { data, length }: the copy shares the data block, and the block + // already holds whatever the elements own + if (auto arrayType = dyn_cast(type)) + { + auto llvmArrayType = tch.convertType(arrayType); + auto dataSlot = rewriter.create(loc, ptrTy, llvmArrayType, slotPtr, + ArrayRef{0, ARRAY_DATA_INDEX}); + emitIncRef(rewriter.create(loc, ptrTy, dataSlot)); + return; + } + + // an interface holds one reference, to its `this`; copying the pair duplicates that + // reference and nothing else, so this stops at the block like the cases above + if (isa(type)) + { + releaseViaInterfaceTag(type, slotPtr, /*retaining=*/true); + return; + } + + // copying a closure duplicates its one reference to the capture box and nothing else + if (isa(type) || isa(type)) + { + releaseViaTagBesideThis(type, slotPtr, CLOSURE_TYPE_INDEX, /*retaining=*/true); + return; + } + + // a tagged union carries its payload inline, so what it holds is copied with it + if (auto unionType = dyn_cast(type)) + { + MLIRTypeHelper mth(rewriter.getContext(), compileOptions); + mlir::Type baseType; + if (mth.isUnionTypeNeedsTag(loc, unionType, baseType)) + { + auto llvmUnionType = tch.convertType(unionType); + auto tagSlot = rewriter.create(loc, ptrTy, llvmUnionType, slotPtr, + ArrayRef{0, UNION_TAG_INDEX}); + auto tagValue = rewriter.create(loc, ptrTy, tagSlot); + auto valueSlot = rewriter.create(loc, ptrTy, llvmUnionType, slotPtr, + ArrayRef{0, UNION_VALUE_INDEX}); + + // see the release side: a zeroed union holds nothing, and the tag has to be + // checked here rather than inside retainViaDescriptor, which reads through it + emitIfNonNull(tagValue, [&]() { retainViaDescriptor(tagValue, valueSlot); }); + } + else + { + retainSlot(baseType, slotPtr); + } + + return; + } + + if (auto optionalType = dyn_cast(type)) + { + buildRetainOptionalBody(optionalType, slotPtr); + return; + } + + // everything left is record-shaped and inline + retainFields(type, slotPtr); + } + + void buildRetainOptionalBody(mlir_ts::OptionalType optionalType, mlir::Value slotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + auto llvmOptionalType = tch.convertType(optionalType); + + auto hasValueSlot = rewriter.create(loc, ptrTy, llvmOptionalType, slotPtr, + ArrayRef{0, OPTIONAL_HASVALUE_INDEX}); + auto hasValue = rewriter.create(loc, th.getLLVMBoolType(), hasValueSlot); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + auto *thenBlock = rewriter.createBlock(continuationBlock); + + rewriter.setInsertionPointToEnd(thenBlock); + auto valueSlot = rewriter.create(loc, ptrTy, llvmOptionalType, slotPtr, + ArrayRef{0, OPTIONAL_VALUE_INDEX}); + retainSlot(optionalType.getElementType(), valueSlot); + rewriter.create(loc, ValueRange{}, continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + rewriter.create(loc, hasValue, thenBlock, continuationBlock); + + rewriter.setInsertionPointToStart(continuationBlock); + } + + void buildOptionalBody(mlir_ts::OptionalType optionalType, mlir::Value slotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + auto llvmOptionalType = tch.convertType(optionalType); + + auto hasValueSlot = rewriter.create(loc, ptrTy, llvmOptionalType, slotPtr, + ArrayRef{0, OPTIONAL_HASVALUE_INDEX}); + auto hasValue = rewriter.create(loc, th.getLLVMBoolType(), hasValueSlot); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + auto *thenBlock = rewriter.createBlock(continuationBlock); + + rewriter.setInsertionPointToEnd(thenBlock); + auto valueSlot = rewriter.create(loc, ptrTy, llvmOptionalType, slotPtr, + ArrayRef{0, OPTIONAL_VALUE_INDEX}); + releaseSlot(optionalType.getElementType(), valueSlot); + rewriter.create(loc, ValueRange{}, continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + rewriter.create(loc, hasValue, thenBlock, continuationBlock); + + rewriter.setInsertionPointToStart(continuationBlock); + } + + void buildArrayBody(mlir_ts::ArrayType arrayType, mlir::Value slotPtr) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto ptrTy = th.getPtrType(); + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto llvmArrayType = tch.convertType(arrayType); + + auto dataSlot = rewriter.create(loc, ptrTy, llvmArrayType, slotPtr, + ArrayRef{0, ARRAY_DATA_INDEX}); + auto dataValue = rewriter.create(loc, ptrTy, dataSlot); + + emitIfLastReference(dataValue, [&]() { + auto elementRoutine = getOrCreateReleaseRoutine(arrayType.getElementType()); + if (!elementRoutine.empty()) + { + auto sizeSlot = rewriter.create(loc, ptrTy, llvmArrayType, slotPtr, + ArrayRef{0, ARRAY_SIZE_INDEX}); + auto sizeValue = rewriter.create(loc, llvmIndexType, sizeSlot); + + emitCountedLoop(sizeValue, [&](mlir::Value index) { + auto llvmElementType = tch.convertType(arrayType.getElementType()); + auto elementPtr = rewriter.create(loc, ptrTy, llvmElementType, dataValue, + ValueRange{index}); + rewriter.create(loc, TypeRange{}, + FlatSymbolRefAttr::get(rewriter.getContext(), elementRoutine), + ValueRange{elementPtr}); + }); + } + + emitFreeBlock(dataValue); + }); + } + + // for (index = 0; index < count; index++) body(index) + void emitCountedLoop(mlir::Value count, llvm::function_ref body) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto llvmIndexType = tch.convertType(th.getIndexType()); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + + auto *conditionBlock = rewriter.createBlock(continuationBlock, {llvmIndexType}, {loc}); + auto *bodyBlock = rewriter.createBlock(continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + auto zero = rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 0)); + rewriter.create(loc, ValueRange{zero}, conditionBlock); + + rewriter.setInsertionPointToEnd(conditionBlock); + auto index = conditionBlock->getArgument(0); + auto keepGoing = rewriter.create(loc, LLVM::ICmpPredicate::slt, index, count); + rewriter.create(loc, keepGoing, bodyBlock, continuationBlock); + + rewriter.setInsertionPointToEnd(bodyBlock); + body(index); + auto one = rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1)); + auto nextIndex = rewriter.create(loc, llvmIndexType, index, one); + rewriter.create(loc, ValueRange{nextIndex}, conditionBlock); + + rewriter.setInsertionPointToStart(continuationBlock); + } +}; + +} // namespace typescript + +#endif // MLIR_TYPESCRIPT_LOWERTOLLVMLOGIC_OWNERSHIPROUTINELOGIC_H_ diff --git a/tslang/include/TypeScript/LowerToLLVM/TypeDescriptorLogic.h b/tslang/include/TypeScript/LowerToLLVM/TypeDescriptorLogic.h new file mode 100644 index 000000000..8880578bb --- /dev/null +++ b/tslang/include/TypeScript/LowerToLLVM/TypeDescriptorLogic.h @@ -0,0 +1,91 @@ +#ifndef MLIR_TYPESCRIPT_LOWERTOLLVMLOGIC_TYPEDESCRIPTORLOGIC_H_ +#define MLIR_TYPESCRIPT_LOWERTOLLVMLOGIC_TYPEDESCRIPTORLOGIC_H_ + +#include "TypeScript/Config.h" +#include "TypeScript/Defines.h" +#include "TypeScript/TypeScriptOps.h" + +#include "TypeScript/LowerToLLVM/TypeHelper.h" +#include "TypeScript/LowerToLLVM/TypeConverterHelper.h" + +using namespace mlir; +namespace mlir_ts = mlir::typescript; + +namespace typescript +{ + +// Reads the static per-type descriptor that sits in front of a runtime type tag. +// +// A tag points at the descriptor's trailing name bytes, so the record itself is at +// `tag - sizeof(record)`. See TYPE_DESCR_* in Defines.h for the layout and why it is a +// cross-module contract; TypeDescriptorOpLowering is what emits the records. +class TypeDescriptorLogic +{ + PatternRewriter &rewriter; + TypeConverterHelper &tch; + TypeHelper th; + Location loc; + + public: + TypeDescriptorLogic(PatternRewriter &rewriter, TypeConverterHelper &tch, Location loc) + : rewriter(rewriter), tch(tch), th(rewriter), loc(loc) + { + } + + // { i32 kind, i32 reserved, ptr release, index blockHeader } + static LLVM::LLVMStructType getRecordType(mlir::OpBuilder &builder, mlir::Type llvmIndexType) + { + auto i32Ty = builder.getI32Type(); + auto ptrTy = LLVM::LLVMPointerType::get(builder.getContext()); + return LLVM::LLVMStructType::getLiteral(builder.getContext(), {i32Ty, i32Ty, ptrTy, ptrTy, llvmIndexType}, false); + } + + LLVM::LLVMStructType getRecordType() + { + return getRecordType(rewriter, tch.convertType(th.getIndexType())); + } + + // Size of the record, and therefore the distance from a tag back to it. The name is a + // byte array, which needs no alignment padding in front of it, so the offset of the name + // within `{ record, [N x i8] }` is exactly the record size regardless of N or target. + mlir::Value getRecordSize() + { + auto ptrTy = th.getPtrType(); + auto llvmIndexType = tch.convertType(th.getIndexType()); + + auto nullPtr = rewriter.create(loc, ptrTy); + auto endAddr = rewriter.create(loc, ptrTy, getRecordType(), nullPtr, ArrayRef{1}); + return rewriter.create(loc, llvmIndexType, endAddr); + } + + mlir::Value getRecordPtrFromTag(mlir::Value tagValue) + { + auto llvmIndexType = tch.convertType(th.getIndexType()); + + auto size = getRecordSize(); + auto negatedSize = rewriter.create(loc, llvmIndexType, + rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 0)), + size); + return rewriter.create(loc, th.getPtrType(), th.getI8Type(), tagValue, ValueRange{negatedSize}); + } + + // TYPE_KIND_* for the type this tag names. + mlir::Value getKindFromTag(mlir::Value tagValue) + { + auto recordPtr = getRecordPtrFromTag(tagValue); + auto kindPtr = rewriter.create(loc, th.getPtrType(), getRecordType(), recordPtr, + ArrayRef{0, TYPE_DESCR_KIND}); + return rewriter.create(loc, th.getI32Type(), kindPtr); + } + + mlir::Value isKind(mlir::Value tagValue, int kind) + { + auto kindValue = getKindFromTag(tagValue); + auto expected = rewriter.create(loc, th.getI32Type(), rewriter.getI32IntegerAttr(kind)); + return rewriter.create(loc, LLVM::ICmpPredicate::eq, kindValue, expected); + } +}; + +} // namespace typescript + +#endif // MLIR_TYPESCRIPT_LOWERTOLLVMLOGIC_TYPEDESCRIPTORLOGIC_H_ diff --git a/tslang/include/TypeScript/LowerToLLVMLogic.h b/tslang/include/TypeScript/LowerToLLVMLogic.h index fa3c88753..4b61e9e43 100644 --- a/tslang/include/TypeScript/LowerToLLVMLogic.h +++ b/tslang/include/TypeScript/LowerToLLVMLogic.h @@ -12,6 +12,7 @@ #include "TypeScript/LowerToLLVM/LLVMTypeConverterHelper.h" #include "TypeScript/LowerToLLVM/CodeLogicHelper.h" #include "TypeScript/LowerToLLVM/LLVMCodeHelper.h" +#include "TypeScript/LowerToLLVM/OwnershipRoutineLogic.h" #include "TypeScript/LowerToLLVM/LLVMRTTIHelperVC.h" #include "TypeScript/LowerToLLVM/AssertLogic.h" #include "TypeScript/LowerToLLVM/DefaultLogic.h" diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRCodeLogic.h b/tslang/include/TypeScript/MLIRLogic/MLIRCodeLogic.h index ebb1c8eda..1fde7013f 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRCodeLogic.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRCodeLogic.h @@ -670,6 +670,83 @@ class MLIRCustomMethods return mlir::Value(); } + // Takes a reference to each value about to be handed to an array's data block. That block + // releases every element it holds when it dies, so it has to have taken one - the same debt + // an array literal carries (§9.21 in docs/reference-counting-evaluation.md). These ops fill + // the block through their own lowering rather than through an assignment, so nothing else on + // the path takes it. + // + // The ops that take an element back out - pop and shift - need no counterpart here, and that + // is not an omission. The block simply stops holding it: the size shrinks past the slot, so + // the release routine never reaches it, and the reference the block held transfers to the + // returned value. That leaves the result carrying the same "+1 nobody has consumed" every + // freshly produced value already carries, which is removed with the rest of the slack rather + // than one op at a time. + // + // What splice deletes is a different matter: those elements are memmoved over and their + // references dropped without a release. That leaks rather than over-releases, so it waits - + // and it cannot be fixed here anyway, because the count to release is only known inside the + // lowering. + // Records that this value already carries a reference for whoever receives it. Only used + // where the transfer is a property of the operation itself - see OWNED_RESULT_ATTR_NAME. + void markResultOwned(mlir::Value value) + { + MLIRTypeHelper mth(builder.getContext(), compileOptions); + if (!value || !mth.ownsHeapMemory(location, value.getType())) + { + return; + } + + if (auto *definingOp = value.getDefiningOp()) + { + definingOp->setAttr(OWNED_RESULT_ATTR_NAME, builder.getUnitAttr()); + } + } + + void retainInsertedElements(ArrayRef values) + { + MLIRTypeHelper mth(builder.getContext(), compileOptions); + for (auto value : values) + { + if (!value || !mth.ownsHeapMemory(location, value.getType())) + { + continue; + } + + // `arr.push(new C())` arrives already owned (§9.25) - the data block takes that + // reference over rather than adding one of its own. + // + // Saying so is not optional. This was the one receiving site that skipped its retain + // without recording the consumption, and once §9.30 began releasing what nothing + // consumed, the pushed value was released at the end of the pushing block - freeing + // an element the array still held. Invisible for as long as the block that pushes is + // also the block that reads, which is why §9.30's own tests missed it; + // `function add() { store.push(new C()) }` reads the freed block. + // + // There is only ever ONE reference to take over, so the second array to receive the + // same value has to retain like any other holder. Reading `OWNED_RESULT` without also + // asking whether it had already been consumed made both of these consume it: + // + // const keep = new Box("kept"); // a const with no storage of its own, so both + // victim.push(keep); // pushes see the `new` itself + // survivors.push(keep); + // + // and the value ended up with two holders and one reference, so whichever array died + // first freed an element the other still held. Nothing to do with `splice` - it + // reproduces with no splice anywhere - but §9.74's release is what made it visible, + // because until then an array that outlived its elements never gave them back. + auto *definingOp = value.getDefiningOp(); + if (definingOp && definingOp->hasAttr(OWNED_RESULT_ATTR_NAME) && + !definingOp->hasAttr(OWNED_RESULT_CONSUMED_ATTR_NAME)) + { + definingOp->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, builder.getUnitAttr()); + continue; + } + + builder.create(location, value); + } + } + ValueOrLogicalResult mlirGenArrayPush(const mlir::Location &location, mlir::Value thisValue, ArrayRef values, std::function castFn, const GenContext &genContext) { @@ -697,6 +774,8 @@ class MLIRCustomMethods return mlir::failure(); } + retainInsertedElements(castedValues); + mlir::Value sizeOfValue = builder.create(location, builder.getIndexType(), thisValueLoaded, mlir::ValueRange{castedValues}); @@ -722,6 +801,12 @@ class MLIRCustomMethods mlir::Value value = builder.create( location, cast(operands.front().getType()).getElementType(), thisValue); + // The data block gives up the element without releasing it - the size shrinks past the + // slot, so its release routine never reaches it again - which hands the block's own + // reference to whoever receives the result. Saying so lets that receiver take it over + // rather than add one of its own (§9.26). + markResultOwned(value); + return value; } @@ -752,6 +837,8 @@ class MLIRCustomMethods return mlir::failure(); } + retainInsertedElements(castedValues); + mlir::Value sizeOfValue = builder.create(location, builder.getIndexType(), thisValueLoaded, mlir::ValueRange{castedValues}); @@ -777,6 +864,9 @@ class MLIRCustomMethods mlir::Value value = builder.create( location, cast(operands.front().getType()).getElementType(), thisValue); + // same transfer as pop, from the front + markResultOwned(value); + return value; } @@ -817,6 +907,8 @@ class MLIRCustomMethods return mlir::failure(); } + retainInsertedElements(castedValues); + mlir::Value sizeOfValue = builder.create(location, builder.getIndexType(), thisValueLoaded, startValue, deleteCountValue, mlir::ValueRange{castedValues}); diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRGenContext.h b/tslang/include/TypeScript/MLIRLogic/MLIRGenContext.h index cd9123e04..32b5bd18a 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRGenContext.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRGenContext.h @@ -65,10 +65,11 @@ struct GenContext passResult = nullptr; capturedVars = nullptr; usingVars = nullptr; + ownedVars = nullptr; currentOperation = nullptr; allocateVarsOutsideOfOperation = false; - allocateUsingVarsOutsideOfOperation = false; + allocateScopeOwnedVarsOutsideOfOperation = false; } void clearReceiverTypes() @@ -143,7 +144,12 @@ struct GenContext bool allowConstEval = false; bool allocateVarsInContextThis = false; bool allocateVarsOutsideOfOperation = false; - bool allocateUsingVarsOutsideOfOperation = false; + // Hoist the storage of scope-owned locals - the ones a `using` declares and the ones that + // own a heap reference - out in front of `currentOperation`, instead of leaving it inside + // the region it was declared in. A `TryOp`'s cleanup region is a sibling of its body, so + // storage declared in the body does not dominate it; hoisting is what lets the unwind leg + // dispose and release what the body's scope owes. + bool allocateScopeOwnedVarsOutsideOfOperation = false; bool forceDiscover = false; bool discoverParamsOnly = false; bool insertIntoParentScope = false; @@ -152,6 +158,15 @@ struct GenContext FunctionPrototypeDOM::TypePtr funcProto; llvm::StringMap *capturedVars = nullptr; llvm::SmallVector *usingVars = nullptr; + // Storage of the locals declared in this scope that took a reference of their own and owe + // a release on the way out. Filled unconditionally, whatever the memory model: what it + // drives are `ts.RetainSlot`/`ts.ReleaseSlot` pairs, which erase whole in a collected + // build (see TypeScriptOps.td). + // + // The storage value is held directly rather than the declaration, so that scope exit + // releases the slot the retain was paired with. Resolving the name again, as the `using` + // list has to, would find whichever declaration shadows it at the exit point. + llvm::SmallVector *ownedVars = nullptr; mlir::Type thisType; mlir_ts::ClassType thisClassType; mlir::Type receiverFuncType; @@ -176,8 +191,16 @@ struct GenContext bool disableSpreadParams = false; const GenContext* parentBlockContext = nullptr; const GenContext* rootContext = nullptr; + // Set by a loop on the context it hands its body, and inherited by every context copied + // from it - so it answers "somewhere inside a loop", not "is the loop". bool isLoop = false; std::string loopLabel; + // Set by the block that becomes the loop's own body scope, and cleared for anything nested + // further in. That is the distinction a `break` or `continue` needs: it owes a dispose and + // a release to every scope between itself and the loop, so the walk outwards can only stop + // at the loop's own scope - and `isLoop` alone reads true for all of them, which is what + // made a `break` written inside an `if` skip the lot. + bool isLoopBodyScope = false; // out-of-band cancellation signal; mutable so stop() stays callable through the const& threading mutable bool stopProcess = false; mlir::SmallVector> *postponedMessages = nullptr; diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRRTTIHelperVCWin32.h b/tslang/include/TypeScript/MLIRLogic/MLIRRTTIHelperVCWin32.h index aeecb7973..dbf083480 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRRTTIHelperVCWin32.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRRTTIHelperVCWin32.h @@ -34,6 +34,9 @@ class MLIRRTTIHelperVCWin32 std::string typeName; std::string typeInfoRef; std::string catchableTypeInfoRef; + // bytes the CRT copies into the catch variable's slot - see catchableTypeSize in + // LLVMRTTIHelperVCWin32Const.h for why getting this wrong corrupts the frame + int catchableTypeSize; }; mlir::OpBuilder &rewriter; @@ -57,7 +60,8 @@ class MLIRRTTIHelperVCWin32 void setF32AsCatchType() { - types.push_back({windows::F32Type::typeName, windows::F32Type::typeInfoRef, windows::F32Type::catchableTypeInfoRef}); + types.push_back({windows::F32Type::typeName, windows::F32Type::typeInfoRef, windows::F32Type::catchableTypeInfoRef, + windows::F32Type::catchableTypeSize}); catchableTypeInfoArrayRef = windows::F32Type::catchableTypeInfoArrayRef; throwInfoRef = windows::F32Type::throwInfoRef; @@ -65,7 +69,8 @@ class MLIRRTTIHelperVCWin32 void setF64AsCatchType() { - types.push_back({windows::F64Type::typeName, windows::F64Type::typeInfoRef, windows::F64Type::catchableTypeInfoRef}); + types.push_back({windows::F64Type::typeName, windows::F64Type::typeInfoRef, windows::F64Type::catchableTypeInfoRef, + windows::F64Type::catchableTypeSize}); catchableTypeInfoArrayRef = windows::F64Type::catchableTypeInfoArrayRef; throwInfoRef = windows::F64Type::throwInfoRef; @@ -73,7 +78,8 @@ class MLIRRTTIHelperVCWin32 void setI32AsCatchType() { - types.push_back({windows::I32Type::typeName, windows::I32Type::typeInfoRef, windows::I32Type::catchableTypeInfoRef}); + types.push_back({windows::I32Type::typeName, windows::I32Type::typeInfoRef, windows::I32Type::catchableTypeInfoRef, + windows::I32Type::catchableTypeSize}); catchableTypeInfoArrayRef = windows::I32Type::catchableTypeInfoArrayRef; throwInfoRef = windows::I32Type::throwInfoRef; @@ -81,8 +87,10 @@ class MLIRRTTIHelperVCWin32 void setStringTypeAsCatchType() { - types.push_back({windows::StringType::typeName, windows::StringType::typeInfoRef, windows::StringType::catchableTypeInfoRef}); - types.push_back({windows::StringType::typeName2, windows::StringType::typeInfoRef2, windows::StringType::catchableTypeInfoRef2}); + types.push_back({windows::StringType::typeName, windows::StringType::typeInfoRef, windows::StringType::catchableTypeInfoRef, + pointerSize()}); + types.push_back({windows::StringType::typeName2, windows::StringType::typeInfoRef2, windows::StringType::catchableTypeInfoRef2, + pointerSize()}); catchableTypeInfoArrayRef = windows::StringType::catchableTypeInfoArrayRef; throwInfoRef = windows::StringType::throwInfoRef; @@ -90,7 +98,8 @@ class MLIRRTTIHelperVCWin32 void setI8PtrAsCatchType() { - types.push_back({windows::I8PtrType::typeName, windows::I8PtrType::typeInfoRef, windows::I8PtrType::catchableTypeInfoRef}); + types.push_back({windows::I8PtrType::typeName, windows::I8PtrType::typeInfoRef, windows::I8PtrType::catchableTypeInfoRef, + pointerSize()}); catchableTypeInfoArrayRef = windows::I8PtrType::catchableTypeInfoArrayRef; throwInfoRef = windows::I8PtrType::throwInfoRef; @@ -102,10 +111,12 @@ class MLIRRTTIHelperVCWin32 { types.push_back({join(name, windows::ClassType::typeName, windows::ClassType::typeNameSuffix), join(name, windows::ClassType::typeInfoRef, windows::ClassType::typeInfoRefSuffix), - join(name, windows::ClassType::catchableTypeInfoRef, windows::ClassType::catchableTypeInfoRefSuffix)}); + join(name, windows::ClassType::catchableTypeInfoRef, windows::ClassType::catchableTypeInfoRefSuffix), + pointerSize()}); } - types.push_back({windows::ClassType::typeName2, windows::ClassType::typeInfoRef2, windows::ClassType::catchableTypeInfoRef2}); + types.push_back({windows::ClassType::typeName2, windows::ClassType::typeInfoRef2, windows::ClassType::catchableTypeInfoRef2, + pointerSize()}); catchableTypeInfoArrayRef = windows::ClassType::catchableTypeInfoArrayRef; throwInfoRef = windows::ClassType::throwInfoRef; @@ -115,14 +126,23 @@ class MLIRRTTIHelperVCWin32 { types.push_back({join(name, windows::ClassType::typeName, windows::ClassType::typeNameSuffix), join(name, windows::ClassType::typeInfoRef, windows::ClassType::typeInfoRefSuffix), - join(name, windows::ClassType::catchableTypeInfoRef, windows::ClassType::catchableTypeInfoRefSuffix)}); + join(name, windows::ClassType::catchableTypeInfoRef, windows::ClassType::catchableTypeInfoRefSuffix), + pointerSize()}); - types.push_back({windows::ClassType::typeName2, windows::ClassType::typeInfoRef2, windows::ClassType::catchableTypeInfoRef2}); + types.push_back({windows::ClassType::typeName2, windows::ClassType::typeInfoRef2, windows::ClassType::catchableTypeInfoRef2, + pointerSize()}); catchableTypeInfoArrayRef = windows::ClassType::catchableTypeInfoArrayRef; throwInfoRef = windows::ClassType::throwInfoRef; } + // A pointer-shaped catchable type (a string, an opaque pointer, a class reference) is as + // wide as the target's pointer, unlike `int` and `double`, which are fixed. + int pointerSize() + { + return compileOptions.sizeBits / 8; + } + std::string join(StringRef name, const char *prefix, const char *suffix) { std::stringstream ss; @@ -392,13 +412,14 @@ class MLIRRTTIHelperVCWin32 { for (auto type : types) { - catchableType(loc, type.catchableTypeInfoRef, type.typeInfoRef, type.typeName); + catchableType(loc, type.catchableTypeInfoRef, type.typeInfoRef, type.typeName, type.catchableTypeSize); } return mlir::success(); } - mlir::LogicalResult catchableType(mlir::Location loc, StringRef catchableTypeInfoRefName, StringRef typeInfoRefName, StringRef typeName) + mlir::LogicalResult catchableType(mlir::Location loc, StringRef catchableTypeInfoRefName, StringRef typeInfoRefName, StringRef typeName, + int catchableTypeSize) { auto name = catchableTypeInfoRefName; if (parentModule.lookupSymbol(name)) @@ -450,7 +471,7 @@ class MLIRRTTIHelperVCWin32 auto itemValue5 = rewriter.create(loc, mth.getI32Type(), rewriter.getI32IntegerAttr(0)); setStructValue(loc, structVal, itemValue5, 4); - auto itemValue6 = rewriter.create(loc, mth.getI32Type(), rewriter.getI32IntegerAttr(8)); + auto itemValue6 = rewriter.create(loc, mth.getI32Type(), rewriter.getI32IntegerAttr(catchableTypeSize)); setStructValue(loc, structVal, itemValue6, 5); auto itemValue7 = rewriter.create(loc, mth.getI32Type(), rewriter.getI32IntegerAttr(0)); diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h index f897f0a5b..c333773f5 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h @@ -2009,6 +2009,47 @@ class MLIRTypeHelper return isUnionTypeNeedsTag(location, unionType, baseType); } + // Does a value of this type own heap memory, directly or through its fields? The same + // question decides both directions: a type with nothing to release has nothing to retain. + // + // Lives here rather than beside the routines it drives (OwnershipRoutineLogic) because + // MLIRGen has to ask it too - it is what decides whether a local is an owner - and the two + // sides disagreeing about which types own memory would place retains that never pair with + // a release. + bool ownsHeapMemory(mlir::Location location, mlir::Type type) + { + llvm::SmallPtrSet visiting; + return ownsHeapMemory(location, type, visiting); + } + + // Field types of a record-shaped type, empty for anything else. + llvm::SmallVector getOwnershipFieldTypes(mlir::Type type) + { + llvm::SmallVector result; + + auto addFields = [&](auto fields) { + for (auto &field : fields) + { + result.push_back(field.type); + } + }; + + if (auto tupleType = dyn_cast(type)) + { + addFields(tupleType.getFields()); + } + else if (auto classStorageType = dyn_cast(type)) + { + addFields(classStorageType.getFields()); + } + else if (auto objectStorageType = dyn_cast(type)) + { + addFields(objectStorageType.getFields()); + } + + return result; + } + bool isUnionTypeNeedsTag(mlir::Location location, mlir_ts::UnionType unionType, mlir::Type &baseType) { auto storeType = getUnionTypeWithMerge(location, unionType.getTypes(), true, true, true); @@ -3554,6 +3595,74 @@ class MLIRTypeHelper } protected: + bool ownsHeapMemory(mlir::Location location, mlir::Type type, llvm::SmallPtrSetImpl &visiting) + { + if (!visiting.insert(type).second) + { + return false; + } + + // owns its own block + if (isa(type) || isa(type) || isa(type) || + isa(type) || isa(type)) + { + return true; + } + + // An interface is a reference to whatever it was made from, and holds it alive the + // same way a class reference does. The concrete layout is not recoverable from the + // type - which is why this used to answer no - but it does not have to be: the value + // carries the runtime type tag of its `this` (INTERFACE_TYPE_INDEX), and release and + // retain go through that, exactly as they do for an `any` box. + if (isa(type)) + { + return true; + } + + // A closure owns its capture box, and the box is not mentioned anywhere in the function + // type - so, like an interface, the value carries a tag beside the pointer and releases + // through it (CLOSURE_TYPE_INDEX). The type cannot say which function values are + // closures, so all of them answer yes and the ones that are not - a plain function + // pointer, a bound method - carry a null tag and cost a call that does nothing. + if (isa(type) || isa(type)) + { + return true; + } + + if (auto unionType = dyn_cast(type)) + { + mlir::Type baseType; + if (isUnionTypeNeedsTag(location, unionType, baseType)) + { + // which member it holds is only known at run time, so the tag's descriptor + // decides - assume it may own something + return true; + } + + return ownsHeapMemory(location, baseType, visiting); + } + + if (auto optionalType = dyn_cast(type)) + { + return ownsHeapMemory(location, optionalType.getElementType(), visiting); + } + + for (auto fieldType : getOwnershipFieldTypes(type)) + { + if (ownsHeapMemory(location, fieldType, visiting)) + { + return true; + } + } + + // Deliberately not owning, each for its own reason: + // - a plain FunctionType is a code pointer and carries nothing; only the bound and + // hybrid forms have a `this` that can be a capture box, and those are above. + // - RefType/ValueRefType point at storage this value does not own. + // - ConstArrayType and ConstTupleType are static data. + return false; + } + std::function getClassInfoByFullName; std::function getGenericClassInfoByFullName; diff --git a/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h b/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h index f6c224b77..05286799a 100644 --- a/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h +++ b/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h @@ -7,6 +7,8 @@ #include "TypeScript/TypeScriptDialect.h" #include "TypeScript/TypeScriptOps.h" +#include "llvm/ADT/StringSwitch.h" + #define DEBUG_TYPE "mlir" using namespace ::typescript; @@ -25,13 +27,76 @@ class TypeOfOpHelper { } - mlir::Value strValue(mlir::Location loc, std::string value) + // The runtime type tag: the same string `typeOfAsString` reports, but pointing into the + // static descriptor for `type` rather than at a bare literal, so the descriptor is + // recoverable from any tag. Every producer of an "any" box tag or a union tag goes + // through here - see TypeScript_TypeDescriptorOp and TYPE_DESCR_* in Defines.h. + mlir::Value typeDescriptorValue(mlir::Location loc, mlir::Type type) { - if (value.empty()) return mlir::Value(); + if (typeOfAsString(type).empty()) return mlir::Value(); auto strType = mlir_ts::StringType::get(rewriter.getContext()); - auto typeOfValue = rewriter.create(loc, strType, rewriter.getStringAttr(value)); - return typeOfValue; + return rewriter.create(loc, strType, mlir::TypeAttr::get(typeOfBaseType(type))); + } + + // The type a descriptor is actually keyed by: the wrappers `typeOfAsString` sees through + // are stripped here too, so every string literal type shares one "string" descriptor + // instead of minting its own. Each distinct class or object still gets its own, which is + // the distinction the descriptor exists to preserve. + static mlir::Type typeOfBaseType(mlir::Type type) + { + if (auto subType = dyn_cast(type)) + { + return typeOfBaseType(subType.getElementType()); + } + + if (auto subType = dyn_cast(type)) + { + return typeOfBaseType(subType.getElementType()); + } + + if (auto subType = dyn_cast(type)) + { + return typeOfBaseType(subType.getElementType()); + } + + if (auto literalType = dyn_cast(type)) + { + return typeOfBaseType(literalType.getElementType()); + } + + return type; + } + + // Coarse category for a name produced by `typeOfAsString`. Deriving the kind from the + // name (rather than re-switching over the type) is what keeps the two from drifting + // apart as `typeOfAsString` grows cases. + static int typeKindFromName(llvm::StringRef name) + { + // "s32", "u64", "f64", "i1", ... - a numeric-width tag, as opposed to a name that + // merely starts with one of those letters ("interface", "symbol", "function"). + if (name.size() > 1 && (name[0] == 'i' || name[0] == 's' || name[0] == 'u' || name[0] == 'f') && + llvm::all_of(name.drop_front(), [](char c) { return c >= '0' && c <= '9'; })) + { + return TYPE_KIND_NUMBER; + } + + return llvm::StringSwitch(name) + .Case("number", TYPE_KIND_NUMBER) + .Case("index", TYPE_KIND_NUMBER) + .Case("string", TYPE_KIND_STRING) + .Case("boolean", TYPE_KIND_BOOLEAN) + .Case("char", TYPE_KIND_CHAR) + .Case("array", TYPE_KIND_ARRAY) + .Case("tuple", TYPE_KIND_TUPLE) + .Case("object", TYPE_KIND_OBJECT) + .Case("class", TYPE_KIND_CLASS) + .Case("interface", TYPE_KIND_INTERFACE) + .Case("function", TYPE_KIND_FUNCTION) + .Case("symbol", TYPE_KIND_SYMBOL) + .Case(UNDEFINED_NAME, TYPE_KIND_UNDEFINED) + .Case("null", TYPE_KIND_NULL) + .Default(TYPE_KIND_UNKNOWN); } std::string typeOfAsString(mlir::Type type) @@ -193,7 +258,7 @@ class TypeOfOpHelper mlir::Value typeOfLogic(mlir::Location loc, mlir::Type type) { - return strValue(loc, typeOfAsString(type)); + return typeDescriptorValue(loc, type); } mlir::Value typeOfLogic(mlir::Location loc, mlir::Value value, mlir::Type origType, CompileOptions& compileOptions) @@ -242,7 +307,9 @@ class TypeOfOpHelper rewriter.setInsertionPointToStart(&elseRegion.back()); - auto undefStrValue = strValue(loc, UNDEFINED_NAME); + // goes through a descriptor like every other tag, so an "any" holding an empty + // optional carries a recoverable descriptor rather than a bare literal + auto undefStrValue = typeDescriptorValue(loc, mlir_ts::UndefinedType::get(rewriter.getContext())); rewriter.create(loc, undefStrValue); rewriter.setInsertionPointAfter(ifOp); diff --git a/tslang/include/TypeScript/Passes.h b/tslang/include/TypeScript/Passes.h index b17c8b7c1..af003521c 100644 --- a/tslang/include/TypeScript/Passes.h +++ b/tslang/include/TypeScript/Passes.h @@ -26,6 +26,16 @@ std::unique_ptr createLowerToLLVMPass(CompileOptions&); // TODO: should you process, switch satate in createLowerToAffinePass to resolve issue? std::unique_ptr createRelocateConstantPass(); +/// Checks that a slot which takes a reference gives it back on every path out of the function, +/// unwind paths included. Runs at the affine level, where unwind paths are ordinary CFG edges, +/// and in every memory model - the ownership ops survive to there regardless of model. +std::unique_ptr createOwnershipVerifierPass(); + +/// Lets a call take over the reference its callee returned instead of retaining a second one. +/// Runs on the whole module, after MLIRGen, because deciding which callees retain their result +/// needs every function to be present - see docs/reference-counting-evaluation.md section 9.27. +std::unique_ptr createOwnedReturnConsumptionPass(CompileOptions&); + /// GC Pass to replace malloc, realloc, free with GC_malloc, GC_realloc, GC_free std::unique_ptr createGCPass(CompileOptions&); /// MemAlloc Pass to replace ts_malloc, ts_realloc, ts_free diff --git a/tslang/include/TypeScript/TypeScriptCompiler/Defines.h b/tslang/include/TypeScript/TypeScriptCompiler/Defines.h index ef01468ba..dca150fdd 100644 --- a/tslang/include/TypeScript/TypeScriptCompiler/Defines.h +++ b/tslang/include/TypeScript/TypeScriptCompiler/Defines.h @@ -24,4 +24,35 @@ enum Exports IgnoreAll }; +// How compiled code reclaims heap memory. There have always been three of these - the flag +// this replaced meant "leak everything", not "collect differently" - but they were spelled +// as one boolean. See docs/reference-counting-evaluation.md. +enum MemoryModel +{ + // Boehm-Demers-Weiser collector. The default, and the only model that reclaims today. + MemoryModelGC, + // Reference counting. In development, and now standing on its own: the block header holds + // the count, the insertion points maintain it, and reaching zero frees through `free`. No + // collector runs behind it, so what the counts miss - a cycle, an unowned case not yet + // covered - leaks rather than being swept up. See sections 9.6 and 9.28. + MemoryModelRC, + // No reclamation at all. + MemoryModelNone +}; + +// The spelling used both by the `-mm=` flag and by the shared-library marker symbol, so the +// two can never disagree about what a model is called. +inline const char *memoryModelName(enum MemoryModel model) +{ + switch (model) + { + case MemoryModelRC: + return "rc"; + case MemoryModelNone: + return "none"; + default: + return "gc"; + } +} + #endif // TYPESCRIPT_COMPILER_DEFINES_H_ \ No newline at end of file diff --git a/tslang/include/TypeScript/TypeScriptOps.td b/tslang/include/TypeScript/TypeScriptOps.td index b54bb9fba..73b8f0634 100644 --- a/tslang/include/TypeScript/TypeScriptOps.td +++ b/tslang/include/TypeScript/TypeScriptOps.td @@ -499,6 +499,116 @@ def TypeScript_TypeOfAnyOp : TypeScript_Op<"TypeOfAny", [Pure]> { let results = (outs TypeScript_String:$typeOf); } +def TypeScript_TypeDescriptorOp : TypeScript_Op<"TypeDescriptor", [Pure]> { + let summary = "type name from the static descriptor of a type"; + let description = [{ + Yields the type name of $descriptorType, as `typeof` reports it. The name is a pointer + into a static per-type descriptor record, so consumers that only want the name treat + this exactly like any other string, while consumers that want the record reach it at + `name - sizeof(descriptor)` - see TYPE_DESCR_* in Defines.h. + + This is what produces the runtime type tag stored in an "any" box and in a tagged + union, so every such tag points into a descriptor. + }]; + + let arguments = (ins TypeAttr:$descriptorType); + let results = (outs TypeScript_String:$name); +} + +def TypeScript_RetainOp : TypeScript_Op<"Retain"> { + let summary = "take one reference to a value"; + let description = [{ + Records that a further owner now holds $reference, so that its heap blocks outlive the + original owner. The mirror of `ts.Release`, and the two are only ever emitted in pairs. + + Under a memory model that is not reference counting this op is erased: a collector needs + no help tracking who holds what, so ownership can be expressed once, in MLIRGen, without + the insertion having to ask which model is in force. + + Retaining a *reference* stops at the block it names. Only values held inline - tuples, + optionals, tagged unions - propagate the retain to what they contain, because copying one + really does duplicate every reference it holds. + }]; + + let arguments = (ins AnyType:$reference); +} + +def TypeScript_ReleaseOp : TypeScript_Op<"Release"> { + let summary = "drop one reference to a value"; + let description = [{ + Gives up one owner's claim on $reference. When it was the last, the value is destroyed - + what it owns is released in turn - and its block freed. + + Erased under a memory model that is not reference counting, like `ts.Retain`. + }]; + + let arguments = (ins AnyType:$reference); +} + +def TypeScript_RetainSlotOp : TypeScript_Op<"RetainSlot"> { + let summary = "take one reference to the value held in a slot"; + let description = [{ + `ts.Retain` for a value that is already in memory: $slot addresses the storage rather than + naming the value. That is the form MLIRGen has at a variable declaration and at scope exit, + and it is also the form the per-type routines take, so nothing has to be spilled to an + alloca on the way. + + Erased under a memory model that is not reference counting - and erased whole, taking the + load with it, which is why ownership insertion can use it in a collected build without + leaving dead reads behind. + }]; + + let arguments = (ins TypeScript_Ref:$slot); +} + +def TypeScript_ReleaseSlotOp : TypeScript_Op<"ReleaseSlot"> { + let summary = "drop one reference to the value held in a slot"; + let description = [{ + The mirror of `ts.RetainSlot`, and `ts.Release` addressed by storage. When the released + reference was the last, the value is destroyed and its block freed. + + Erased under a memory model that is not reference counting. + }]; + + let arguments = (ins TypeScript_Ref:$slot); +} + +def TypeScript_RetainCellOp : TypeScript_Op<"RetainCell"> { + let summary = "take one reference to a captured variable's storage"; + let description = [{ + A variable a closure captures by reference does not live in the frame: its storage is a + heap block of its own - a *cell* - so that the frame and every closure over it read and + write the same value. $slot addresses that cell, and this records one more owner of the + cell itself, not of the value inside it. + + That is what separates this from `ts.RetainSlot`, which addresses storage in order to + reach the value in it. A cell has two kinds of owner: the frame that declared the + variable, and each capture box that captured it. + + Erased under a memory model that is not reference counting. + }]; + + let arguments = (ins TypeScript_Ref:$slot); +} + +def TypeScript_ReleaseCellOp : TypeScript_Op<"ReleaseCell"> { + let summary = "drop one reference to a captured variable's storage"; + let description = [{ + The mirror of `ts.RetainCell`. When the dropped reference was the cell's last, the value + in the cell is released and the cell freed - in that order, because releasing the value + reads the cell. + + A scope exit releasing a captured local emits this rather than `ts.ReleaseSlot`: the + frame is giving up the *cell*, and the value inside it outlives the frame whenever a + closure does. Assigning to the variable still releases the old value with + `ts.ReleaseSlot`, which is the other question and unaffected. + + Erased under a memory model that is not reference counting. + }]; + + let arguments = (ins TypeScript_Ref:$slot); +} + def TypeScript_SizeOfOp : TypeScript_Op<"SizeOf", [Pure]> { let summary = "size of type"; let description = [{ diff --git a/tslang/include/TypeScript/TypeScriptPassContext.h b/tslang/include/TypeScript/TypeScriptPassContext.h index 50aefecf2..0539f9ac4 100644 --- a/tslang/include/TypeScript/TypeScriptPassContext.h +++ b/tslang/include/TypeScript/TypeScriptPassContext.h @@ -25,6 +25,11 @@ struct TSContext mlir::DenseMap cleanup; mlir::DenseMap parentTryOp; mlir::DenseMap landingBlockOf; + // Throws that sit inside a catch clause and therefore have to end the active catch before + // they leave it. `return`, `break` and `continue` carry the same meaning in `unwind`, but + // a throw cannot: `unwind` already means its invoke destination, which is a different + // question with a different answer. + mlir::DenseSet leavesCatch; mlir::Block *returnBlock; }; diff --git a/tslang/include/TypeScript/VSCodeTemplate/Files.h b/tslang/include/TypeScript/VSCodeTemplate/Files.h index e5db9a228..5d16f82e9 100644 --- a/tslang/include/TypeScript/VSCodeTemplate/Files.h +++ b/tslang/include/TypeScript/VSCodeTemplate/Files.h @@ -1,12 +1,11 @@ -#define NODE_MODULE_TSLANG_PATH "node_modules/tslang" +#define TYPES_TSLANG_PATH "types/tslang" #define DOT_VSCODE_PATH ".vscode" const auto TSCONFIG_JSON_DATA = R"raw( { "compilerOptions": { - "target": "es2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, + "target": "esnext", + "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, @@ -17,10 +16,10 @@ const auto TSCONFIG_JSON_DATA = R"raw( "isolatedModules": true, "jsx": "preserve", "incremental": true, - "types": ["tslang"] + "lib": ["ESNext"], + "types": ["./types/tslang", "<>/defaultlib/lib.d.ts"] }, - "include": ["<>.ts"], - "exclude": ["node_modules"] + "include": <> } )raw"; @@ -85,7 +84,6 @@ const auto TASKS_JSON_DATA = R"raw( "--llvm-lib-path=<>", "--tslang-lib-path=<>", "--default-lib-path=<>", - "--no-default-lib", "--di", "--opt_level=0", "--emit=exe", @@ -109,7 +107,6 @@ const auto TASKS_JSON_DATA = R"raw( "--llvm-lib-path=<>", "--tslang-lib-path=<>", "--default-lib-path=<>", - "--no-default-lib", "--opt", "--opt_level=3", "--emit=exe", @@ -133,10 +130,9 @@ const auto TASKS_JSON_DATA = R"raw( "--llvm-lib-path=<>", "--tslang-lib-path=<>", "--default-lib-path=<>", - "--no-default-lib", "--di", "--opt_level=0", - "--emit=exe", + "--emit=dll", "${file}" ], "group": { @@ -157,10 +153,9 @@ const auto TASKS_JSON_DATA = R"raw( "--llvm-lib-path=<>", "--tslang-lib-path=<>", "--default-lib-path=<>", - "--no-default-lib", "--opt", "--opt_level=3", - "--emit=exe", + "--emit=dll", "${file}" ], "group": { @@ -286,7 +281,6 @@ const auto LAUNCH_JSON_DATA_LINUX = R"raw( "--llvm-lib-path=<>", "--tslang-lib-path=<>", "--default-lib-path=<>", - "--no-default-lib", "--opt", "--opt_level=3", "--emit=jit", @@ -316,16 +310,21 @@ enable_language(TSLANG) # Include folders include_directories(${CMAKE_TSLANG_DIR}/defaultlib) -# The compiled default lib is split into per-build subfolders (debug/release); -# pick the one matching this build so the CRT and default-lib binaries agree. +# The compiled default lib is split into per-build subfolders (debug/release) and then per +# memory model (gc/rc/none); pick the pair matching this build, so that the CRT, the allocator +# and the default-lib binaries all agree. A library built for one model cannot be linked into +# a program built for another. if (CMAKE_BUILD_TYPE STREQUAL "Release") set(TSLANG_DEFAULTLIB_BUILD "release") else() set(TSLANG_DEFAULTLIB_BUILD "debug") endif() +set(TSLANG_MEMORY_MODEL "gc" CACHE STRING "Memory model of compiled code: gc, rc or none") +set_property(CACHE TSLANG_MEMORY_MODEL PROPERTY STRINGS gc rc none) + # Lib folders -link_directories(${CMAKE_TSLANG_DIR} ${CMAKE_TSLANG_DIR}/defaultlib/lib/${TSLANG_DEFAULTLIB_BUILD}) +link_directories(${CMAKE_TSLANG_DIR} ${CMAKE_TSLANG_DIR}/defaultlib/lib/${TSLANG_DEFAULTLIB_BUILD}/${TSLANG_MEMORY_MODEL}) # set options if (CMAKE_BUILD_TYPE STREQUAL "Release") @@ -334,6 +333,10 @@ else() set(CMAKE_TSLANG_FLAGS "--di --opt_level=0") # global endif() +# The same variable that picked the link directory has to reach the compiler as well, or the +# program is compiled for one model and linked against another model's default lib. +set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -mm=${TSLANG_MEMORY_MODEL}") # global + if(WIN32) else() set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -relocation-model=pic") # global @@ -347,7 +350,13 @@ add_executable(${PROJECT_NAME} ) # required libs -set(TSLANG_LINK_LIBS "TypeScriptDefaultLib" "TypeScriptAsyncRuntime" "gc" "LLVMSupport") +set(TSLANG_LINK_LIBS "TypeScriptDefaultLib" "TypeScriptAsyncRuntime" "LLVMSupport") + +# Boehm is only referenced by the gc default lib; the rc and none builds allocate through the +# CRT and must not drag a collector in. +if (TSLANG_MEMORY_MODEL STREQUAL "gc") + list(APPEND TSLANG_LINK_LIBS "gc") +endif() # ntdll provides RtlGetLastNtStatus (pulled in by LLVMSupport) on Windows if(WIN32) @@ -403,7 +412,7 @@ const auto CMAKE_MYCODE_TS_DATA = R"raw(// Example source in TypeScript language // with main.cpp. Replace with real TypeScript syntax; the symbols exported // must match the extern "C" declarations in main.cpp. -import './adder' +import { Adder } from './adder' export function foo_add(a: int, b: int): int { const adder = new Adder(a, b); @@ -477,6 +486,19 @@ set_source_files_properties(mycode.ts PROPERTIES COMPILE_OPTIONS "--define;TSLANG=1") # per-file ``` +## Memory model + +The default library is compiled separately for each memory model, and a program has to link +the build matching the model it was compiled with. One variable drives both: + +``` +cmake --preset default -DTSLANG_MEMORY_MODEL=rc +``` + +It selects `defaultlib/lib//` as the link directory and adds `-mm=` +to the compile flags, so the two cannot disagree. Valid values are `gc` (default), `rc` and +`none`; only `gc` links Boehm. + ## Minimal alternative If you don't need a first-class language, either: diff --git a/tslang/include/TypeScript/Version.h b/tslang/include/TypeScript/Version.h index de469b5fd..11532026f 100644 --- a/tslang/include/TypeScript/Version.h +++ b/tslang/include/TypeScript/Version.h @@ -1,3 +1,7 @@ #ifndef TSLANG_PACKAGE_VERSION #define TSLANG_PACKAGE_VERSION "0.0.0-not-set" +#endif + +#ifndef TSLANG_GIT_COMMIT_HASH +#define TSLANG_GIT_COMMIT_HASH "unknown" #endif \ No newline at end of file diff --git a/tslang/lib/AsyncRuntimeCommon.inc b/tslang/lib/AsyncRuntimeCommon.inc new file mode 100644 index 000000000..eb424bb0c --- /dev/null +++ b/tslang/lib/AsyncRuntimeCommon.inc @@ -0,0 +1,560 @@ +//===- AsyncRuntimeCommon.inc - Async runtime reference implementation ----===// +// +// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +//===----------------------------------------------------------------------===// +// +// This file implements basic Async runtime API for supporting Async dialect +// to LLVM dialect lowering. +// +// Shared by TypeScriptRuntime/AsyncRuntime.cpp (built SHARED, for the JIT) and +// TypeScriptAsyncRuntime/AsyncRuntime.cpp (built STATIC, linked into AOT +// executables). Each of those files #includes this one inside its own +// MLIR_ASYNCRUNTIME_DEFINE_FUNCTIONS guard and appends its own consumer-specific +// tail (JIT export table vs. the MSVC `aligned_alloc` shim). +// +//===----------------------------------------------------------------------===// + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "llvm/ADT/StringMap.h" +#include "llvm/Support/ThreadPool.h" + +#include "TypeScript/AsyncGCThreads.h" + +using namespace mlir::runtime; + +// Called once from the entry point: the GC pass injects the call beside GC_init, so it happens +// only in a `gc` build. When linked into the shared TypeScriptRuntime, this symbol is also +// exported under this name in TypeScriptRuntime.def for the JIT to resolve. +extern "C" void GC_enable_threads() +{ + typescript::asyncgc::enableThreads(); +} + +//===----------------------------------------------------------------------===// +// Async runtime API. +//===----------------------------------------------------------------------===// + +namespace mlir +{ +namespace runtime +{ +namespace +{ + + // Forward declare class defined below. + class RefCounted; + + // -------------------------------------------------------------------------- // + // AsyncRuntime orchestrates all async operations and Async runtime API is built + // on top of the default runtime instance. + // -------------------------------------------------------------------------- // + + class AsyncRuntime + { + public: + AsyncRuntime() : numRefCountedObjects(0) + { + } + + ~AsyncRuntime() + { + threadPool.wait(); // wait for the completion of all async tasks + assert(getNumRefCountedObjects() == 0 && "all ref counted objects must be destroyed"); + } + + int64_t getNumRefCountedObjects() + { + return numRefCountedObjects.load(std::memory_order_relaxed); + } + + llvm::ThreadPoolInterface &getThreadPool() + { + return threadPool; + } + + private: + friend class RefCounted; + + // Count the total number of reference counted objects in this instance + // of an AsyncRuntime. For debugging purposes only. + void addNumRefCountedObjects() + { + numRefCountedObjects.fetch_add(1, std::memory_order_relaxed); + } + void dropNumRefCountedObjects() + { + numRefCountedObjects.fetch_sub(1, std::memory_order_relaxed); + } + + std::atomic numRefCountedObjects; + llvm::DefaultThreadPool threadPool; + }; + + // -------------------------------------------------------------------------- // + // A state of the async runtime value (token, value or group). + // -------------------------------------------------------------------------- // + + class State + { + public: + enum StateEnum : int8_t + { + // The underlying value is not yet available for consumption. + kUnavailable = 0, + // The underlying value is available for consumption. This state can not + // transition to any other state. + kAvailable = 1, + // This underlying value is available and contains an error. This state can + // not transition to any other state. + kError = 2, + }; + + /* implicit */ State(StateEnum s) : state(s) + { + } + /* implicit */ operator StateEnum() + { + return state; + } + + bool isUnavailable() const + { + return state == kUnavailable; + } + bool isAvailable() const + { + return state == kAvailable; + } + bool isError() const + { + return state == kError; + } + bool isAvailableOrError() const + { + return isAvailable() || isError(); + } + + const char *debug() const + { + switch (state) + { + case kUnavailable: + return "unavailable"; + case kAvailable: + return "available"; + case kError: + return "error"; + } + } + + private: + StateEnum state; + }; + + // -------------------------------------------------------------------------- // + // A base class for all reference counted objects created by the async runtime. + // -------------------------------------------------------------------------- // + + class RefCounted + { + public: + RefCounted(AsyncRuntime *runtime, int64_t refCount = 1) : runtime(runtime), refCount(refCount) + { + runtime->addNumRefCountedObjects(); + } + + virtual ~RefCounted() + { + assert(refCount.load() == 0 && "reference count must be zero"); + runtime->dropNumRefCountedObjects(); + } + + RefCounted(const RefCounted &) = delete; + RefCounted &operator=(const RefCounted &) = delete; + + void addRef(int64_t count = 1) + { + refCount.fetch_add(count); + } + + void dropRef(int64_t count = 1) + { + int64_t previous = refCount.fetch_sub(count); + assert(previous >= count && "reference count should not go below zero"); + if (previous == count) + destroy(); + } + + protected: + virtual void destroy() + { + delete this; + } + + private: + AsyncRuntime *runtime; + std::atomic refCount; + }; + +} // namespace + +// Returns the default per-process instance of an async runtime. +static std::unique_ptr &getDefaultAsyncRuntimeInstance() +{ + static auto runtime = std::make_unique(); + return runtime; +} + +static void resetDefaultAsyncRuntime() +{ + return getDefaultAsyncRuntimeInstance().reset(); +} + +static AsyncRuntime *getDefaultAsyncRuntime() +{ + return getDefaultAsyncRuntimeInstance().get(); +} + +// Async token provides a mechanism to signal asynchronous operation completion. +struct AsyncToken : public RefCounted +{ + // AsyncToken created with a reference count of 2 because it will be returned + // to the `async.execute` caller and also will be later on emplaced by the + // asynchronously executed task. If the caller immediately will drop its + // reference we must ensure that the token will be alive until the + // asynchronous operation is completed. + AsyncToken(AsyncRuntime *runtime) : RefCounted(runtime, /*refCount=*/2), state(State::kUnavailable) + { + } + + std::atomic state; + + // Pending awaiters are guarded by a mutex. + std::mutex mu; + std::condition_variable cv; + std::vector> awaiters; +}; + +// Async value provides a mechanism to access the result of asynchronous +// operations. It owns the storage that is used to store/load the value of the +// underlying type, and a flag to signal if the value is ready or not. +struct AsyncValue : public RefCounted +{ + // AsyncValue similar to an AsyncToken created with a reference count of 2. + AsyncValue(AsyncRuntime *runtime, int64_t size) + : RefCounted(runtime, /*refCount=*/2), state(State::kUnavailable), storage(size) + { + } + + std::atomic state; + + // Use vector of bytes to store async value payload. + std::vector storage; + + // Pending awaiters are guarded by a mutex. + std::mutex mu; + std::condition_variable cv; + std::vector> awaiters; +}; + +// Async group provides a mechanism to group together multiple async tokens or +// values to await on all of them together (wait for the completion of all +// tokens or values added to the group). +struct AsyncGroup : public RefCounted +{ + AsyncGroup(AsyncRuntime *runtime, int64_t size) : RefCounted(runtime), pendingTokens(size), numErrors(0), rank(0) + { + } + + std::atomic pendingTokens; + std::atomic numErrors; + std::atomic rank; + + // Pending awaiters are guarded by a mutex. + std::mutex mu; + std::condition_variable cv; + std::vector> awaiters; +}; + +// Adds references to reference counted runtime object. +extern "C" void mlirAsyncRuntimeAddRef(RefCountedObjPtr ptr, int64_t count) +{ + RefCounted *refCounted = static_cast(ptr); + refCounted->addRef(count); +} + +// Drops references from reference counted runtime object. +extern "C" void mlirAsyncRuntimeDropRef(RefCountedObjPtr ptr, int64_t count) +{ + RefCounted *refCounted = static_cast(ptr); + refCounted->dropRef(count); +} + +// Creates a new `async.token` in not-ready state. +extern "C" AsyncToken *mlirAsyncRuntimeCreateToken() +{ + AsyncToken *token = new AsyncToken(getDefaultAsyncRuntime()); + return token; +} + +// Creates a new `async.value` in not-ready state. +extern "C" AsyncValue *mlirAsyncRuntimeCreateValue(int64_t size) +{ + AsyncValue *value = new AsyncValue(getDefaultAsyncRuntime(), size); + return value; +} + +// Create a new `async.group` in empty state. +extern "C" AsyncGroup *mlirAsyncRuntimeCreateGroup(int64_t size) +{ + AsyncGroup *group = new AsyncGroup(getDefaultAsyncRuntime(), size); + return group; +} + +extern "C" int64_t mlirAsyncRuntimeAddTokenToGroup(AsyncToken *token, AsyncGroup *group) +{ + std::unique_lock lockToken(token->mu); + std::unique_lock lockGroup(group->mu); + + // Get the rank of the token inside the group before we drop the reference. + int rank = group->rank.fetch_add(1); + + // HACK: ASD: to support dynamic size + group->pendingTokens.fetch_add(1); + + auto onTokenReady = [group, token]() + { + // Increment the number of errors in the group. + if (State(token->state).isError()) + group->numErrors.fetch_add(1); + + // If pending tokens go below zero it means that more tokens than the group + // size were added to this group. + assert(group->pendingTokens > 0 && "wrong group size"); + + // Run all group awaiters if it was the last token in the group. + if (group->pendingTokens.fetch_sub(1) == 1) + { + group->cv.notify_all(); + for (auto &awaiter : group->awaiters) + awaiter(); + } + }; + + if (State(token->state).isAvailableOrError()) + { + // Update group pending tokens immediately and maybe run awaiters. + onTokenReady(); + } + else + { + // Update group pending tokens when token will become ready. Because this + // will happen asynchronously we must ensure that `group` is alive until + // then, and re-ackquire the lock. + group->addRef(); + + token->awaiters.emplace_back([group, onTokenReady]() + { + // Make sure that `dropRef` does not destroy the mutex owned by the lock. + { + std::unique_lock lockGroup(group->mu); + onTokenReady(); + } + group->dropRef(); }); + } + + return rank; +} + +// Switches `async.token` to available or error state (terminatl state) and runs +// all awaiters. +static void setTokenState(AsyncToken *token, State state) +{ + assert(state.isAvailableOrError() && "must be terminal state"); + assert(State(token->state).isUnavailable() && "token must be unavailable"); + + // Make sure that `dropRef` does not destroy the mutex owned by the lock. + { + std::unique_lock lock(token->mu); + token->state = state; + token->cv.notify_all(); + for (auto &awaiter : token->awaiters) + awaiter(); + } + + // Async tokens created with a ref count `2` to keep token alive until the + // async task completes. Drop this reference explicitly when token emplaced. + token->dropRef(); +} + +static void setValueState(AsyncValue *value, State state) +{ + assert(state.isAvailableOrError() && "must be terminal state"); + assert(State(value->state).isUnavailable() && "value must be unavailable"); + + // Make sure that `dropRef` does not destroy the mutex owned by the lock. + { + std::unique_lock lock(value->mu); + value->state = state; + value->cv.notify_all(); + for (auto &awaiter : value->awaiters) + awaiter(); + } + + // Async values created with a ref count `2` to keep value alive until the + // async task completes. Drop this reference explicitly when value emplaced. + value->dropRef(); +} + +extern "C" void mlirAsyncRuntimeEmplaceToken(AsyncToken *token) +{ + setTokenState(token, State::kAvailable); +} + +extern "C" void mlirAsyncRuntimeEmplaceValue(AsyncValue *value) +{ + setValueState(value, State::kAvailable); +} + +extern "C" void mlirAsyncRuntimeSetTokenError(AsyncToken *token) +{ + setTokenState(token, State::kError); +} + +extern "C" void mlirAsyncRuntimeSetValueError(AsyncValue *value) +{ + setValueState(value, State::kError); +} + +extern "C" bool mlirAsyncRuntimeIsTokenError(AsyncToken *token) +{ + return State(token->state).isError(); +} + +extern "C" bool mlirAsyncRuntimeIsValueError(AsyncValue *value) +{ + return State(value->state).isError(); +} + +extern "C" bool mlirAsyncRuntimeIsGroupError(AsyncGroup *group) +{ + return group->numErrors.load() > 0; +} + +extern "C" void mlirAsyncRuntimeAwaitToken(AsyncToken *token) +{ + std::unique_lock lock(token->mu); + if (!State(token->state).isAvailableOrError()) + token->cv.wait(lock, [token] + { return State(token->state).isAvailableOrError(); }); +} + +extern "C" void mlirAsyncRuntimeAwaitValue(AsyncValue *value) +{ + std::unique_lock lock(value->mu); + if (!State(value->state).isAvailableOrError()) + value->cv.wait(lock, [value] + { return State(value->state).isAvailableOrError(); }); +} + +extern "C" void mlirAsyncRuntimeAwaitAllInGroup(AsyncGroup *group) +{ + std::unique_lock lock(group->mu); + if (group->pendingTokens != 0) + group->cv.wait(lock, [group] + { return group->pendingTokens == 0; }); +} + +// Returns a pointer to the storage owned by the async value. +extern "C" ValueStorage mlirAsyncRuntimeGetValueStorage(AsyncValue *value) +{ + assert(!State(value->state).isError() && "unexpected error state"); + return value->storage.data(); +} + +extern "C" void mlirAsyncRuntimeExecute(CoroHandle handle, CoroResume resume) +{ + auto *runtime = getDefaultAsyncRuntime(); + runtime->getThreadPool().async([handle, resume]() + { typescript::asyncgc::ThreadRegistration gcThread; (*resume)(handle); }); +} + +extern "C" void mlirAsyncRuntimeAwaitTokenAndExecute(AsyncToken *token, CoroHandle handle, CoroResume resume) +{ + auto execute = [handle, resume]() + { (*resume)(handle); }; + std::unique_lock lock(token->mu); + if (State(token->state).isAvailableOrError()) + { + lock.unlock(); + execute(); + } + else + { + token->awaiters.emplace_back([execute]() + { execute(); }); + } +} + +extern "C" void mlirAsyncRuntimeAwaitValueAndExecute(AsyncValue *value, CoroHandle handle, CoroResume resume) +{ + auto execute = [handle, resume]() + { (*resume)(handle); }; + std::unique_lock lock(value->mu); + if (State(value->state).isAvailableOrError()) + { + lock.unlock(); + execute(); + } + else + { + value->awaiters.emplace_back([execute]() + { execute(); }); + } +} + +extern "C" void mlirAsyncRuntimeAwaitAllInGroupAndExecute(AsyncGroup *group, CoroHandle handle, CoroResume resume) +{ + auto execute = [handle, resume]() + { (*resume)(handle); }; + std::unique_lock lock(group->mu); + if (group->pendingTokens == 0) + { + lock.unlock(); + execute(); + } + else + { + group->awaiters.emplace_back([execute]() + { execute(); }); + } +} + +extern "C" int64_t mlirAsyncRuntimGetNumWorkerThreads() +{ + return getDefaultAsyncRuntime()->getThreadPool().getMaxConcurrency(); +} + +//===----------------------------------------------------------------------===// +// Small async runtime support library for testing. +//===----------------------------------------------------------------------===// + +extern "C" void mlirAsyncRuntimePrintCurrentThreadId() +{ + static thread_local std::thread::id thisId = std::this_thread::get_id(); + std::cout << "Current thread id: " << thisId << std::endl; +} + +} // namespace runtime +} // namespace mlir diff --git a/tslang/lib/TypeScript/CMakeLists.txt b/tslang/lib/TypeScript/CMakeLists.txt index e64cd56f4..293019953 100644 --- a/tslang/lib/TypeScript/CMakeLists.txt +++ b/tslang/lib/TypeScript/CMakeLists.txt @@ -31,6 +31,8 @@ add_mlir_dialect_library(MLIRTypeScript LowerToAffineLoops.cpp LowerToLLVM.cpp RelocateConstantPass.cpp + OwnershipVerifierPass.cpp + OwnedReturnConsumptionPass.cpp GCPass.cpp ObjDumper.cpp DeclarationPrinter.cpp diff --git a/tslang/lib/TypeScript/GCPass.cpp b/tslang/lib/TypeScript/GCPass.cpp index 723cfef8a..01b8691e8 100644 --- a/tslang/lib/TypeScript/GCPass.cpp +++ b/tslang/lib/TypeScript/GCPass.cpp @@ -22,6 +22,9 @@ namespace mlir_ts = mlir::typescript; namespace { +// what LLVMCodeHelperBase::_MemoryAlloc asks for when it wants a zeroed block +constexpr auto CALLOC_NAME = "calloc"; + class GCPass : public mlir::PassWrapper { public: @@ -40,6 +43,9 @@ class GCPass : public mlir::PassWrapper LLVM_DEBUG(llvm::dbgs() << "\n!! GCPass: BEFORE DUMP: \n" << m << "\n";); auto added = false; + llvm::SmallVector redundantMemSets; + llvm::SmallVector callocCalls; + llvm::SmallVector callocDecls; m.walk([&](mlir::Operation *op) { // process gctors first if (auto funcOp = dyn_cast_or_null(op)) @@ -51,6 +57,12 @@ class GCPass : public mlir::PassWrapper } auto name = std::string(symbolAttr.getValue()); + if (name == CALLOC_NAME) + { + callocDecls.push_back(funcOp); + return; + } + if (!funcOp.getBody().empty()) { if (!added) @@ -69,6 +81,17 @@ class GCPass : public mlir::PassWrapper renameFunction(name, funcOp); } + if (auto memsetOp = dyn_cast_or_null(op)) + { + if (zeroesAGCAllocation(memsetOp)) + { + // erased after the walk, not during it + redundantMemSets.push_back(memsetOp); + } + + return; + } + if (auto callOp = dyn_cast_or_null(op)) { if (!callOp.getCallee().has_value()) @@ -77,10 +100,9 @@ class GCPass : public mlir::PassWrapper } auto name = callOp.getCallee().value(); - if (name == "memset") + if (name == CALLOC_NAME) { - removeRedundantMemSet(callOp); - + callocCalls.push_back(callOp); return; } @@ -88,6 +110,13 @@ class GCPass : public mlir::PassWrapper } }); + for (auto memsetOp : redundantMemSets) + { + memsetOp.erase(); + } + + replaceCallocWithGCMalloc(m, callocCalls, callocDecls); + if (!added) { // process main @@ -104,9 +133,12 @@ class GCPass : public mlir::PassWrapper LLVM_DEBUG(llvm::dbgs() << "\n!! GCPass: AFTER DUMP: \n" << m << "\n";); } + // `calloc` is deliberately absent here: it takes two arguments where GC_malloc takes one, so + // a rename in place would leave a call whose arity disagrees with its callee. It goes through + // replaceCallocWithGCMalloc instead. bool mapName(StringRef name, StringRef modeName, StringRef &newName) { - if (name == "malloc" || name == "calloc") + if (name == "malloc") { if (modeName == "atomic") { @@ -190,6 +222,13 @@ class GCPass : public mlir::PassWrapper llvm::SmallVector passthrough; if (auto existing = funcOp.getPassthroughAttr()) { + // one declaration, many call sites: this runs once per call for the injected + // declarations, and a second `allockind` entry would be a duplicate LLVM attribute + if (llvm::is_contained(existing, kindEntry)) + { + return; + } + passthrough.append(existing.begin(), existing.end()); } passthrough.push_back(kindEntry); @@ -230,6 +269,44 @@ class GCPass : public mlir::PassWrapper markAsAllocatorIfNeeded("GC_malloc_atomic", gcInitFuncOp); } + // `calloc(1, n)` is how a zeroed block is asked for (LLVMCodeHelperBase::_MemoryAlloc), and + // GC_malloc already returns zeroed memory - so the count argument is dropped and the size + // handed straight over. Rewritten rather than renamed because the arity differs; a rename in + // place would leave a two-argument call to a one-argument callee. + // + // The declarations go too. Every `calloc` in the module came from _MemoryAlloc, so once the + // calls are gone nothing names them, and a declaration left behind would make the linked + // program depend on libc's allocator for a symbol it never calls. + void replaceCallocWithGCMalloc(mlir::ModuleOp module, llvm::SmallVector &calls, + llvm::SmallVector &decls) + { + if (calls.empty() && decls.empty()) + { + return; + } + + PatternRewriter rewriter(module.getContext()); + TypeHelper th(module.getContext()); + + for (auto callOp : calls) + { + LLVMCodeHelper ch(callOp, rewriter, nullptr, tsContext.compileOptions); + auto sizeValue = callOp.getOperand(1); + auto gcMallocFuncOp = ch.getOrInsertFunction( + "GC_malloc", th.getFunctionType(th.getPtrType(), mlir::ArrayRef{sizeValue.getType()})); + markAsAllocatorIfNeeded("GC_malloc", gcMallocFuncOp); + + rewriter.setInsertionPoint(callOp); + auto gcMallocCall = rewriter.create(callOp->getLoc(), gcMallocFuncOp, ValueRange{sizeValue}); + rewriter.replaceOp(callOp, gcMallocCall.getResults()); + } + + for (auto funcOp : decls) + { + funcOp.erase(); + } + } + void injectInit(LLVM::LLVMFuncOp funcOp) { PatternRewriter rewriter(funcOp.getContext()); @@ -239,28 +316,35 @@ class GCPass : public mlir::PassWrapper auto i8PtrTy = th.getPtrType(); auto gcInitFuncOp = ch.getOrInsertFunction("GC_init", th.getFunctionType(th.getVoidType(), mlir::ArrayRef{})); + // The async runtime resumes coroutines on a thread pool, and a resumed coroutine both + // allocates and hands its own frame back through the collector. Boehm does not lock its + // allocator until it is told there is more than one thread, and it will not let a thread + // register itself until the same call has been made - so without this, a worker and the + // awaiting thread walked the same free lists with no lock (see AsyncGCThreads.h). It goes + // here rather than inside GC_init because an ahead-of-time build links the collector's own + // GC_init, which there is no hooking; this pass runs only for `-mm=gc`, which is exactly + // when it is wanted. + auto gcEnableThreadsFuncOp = ch.getOrInsertFunction( + "GC_enable_threads", th.getFunctionType(th.getVoidType(), mlir::ArrayRef{})); + rewriter.setInsertionPointToStart(&*funcOp.getBody().begin()); rewriter.create(funcOp->getLoc(), gcInitFuncOp, ValueRange{}); + rewriter.create(funcOp->getLoc(), gcEnableThreadsFuncOp, ValueRange{}); } - void removeRedundantMemSet(LLVM::CallOp memSetCallOp) + // GC_malloc hands back zeroed memory, so zeroing the block it just returned is wasted work. + bool zeroesAGCAllocation(LLVM::MemsetOp memSetOp) { - // this is memset, find out if it is used by GC_malloc - LLVM_DEBUG(llvm::dbgs() << "DBG: " << memSetCallOp.getOperand(0) << "\n";); - if (auto probMemAllocCall = dyn_cast_or_null(memSetCallOp.getOperand(0).getDefiningOp())) + LLVM_DEBUG(llvm::dbgs() << "DBG: " << memSetOp.getDst() << "\n";); + auto probMemAllocCall = dyn_cast_or_null(memSetOp.getDst().getDefiningOp()); + if (!probMemAllocCall || !probMemAllocCall.getCallee().has_value()) { - if (!probMemAllocCall.getCallee().has_value()) - { - return; - } - - auto name = probMemAllocCall.getCallee().value(); - if (name == "GC_malloc") - { - PatternRewriter rewriter(memSetCallOp.getContext()); - rewriter.replaceOp(memSetCallOp, ValueRange{probMemAllocCall.getResult()}); - } + return false; } + + // The allocation is renamed before this runs - the walk reaches it first, since it + // defines the pointer being zeroed - so the name to match is the GC one. + return probMemAllocCall.getCallee().value() == "GC_malloc"; } }; } // end anonymous namespace diff --git a/tslang/lib/TypeScript/LowerToAffineLoops.cpp b/tslang/lib/TypeScript/LowerToAffineLoops.cpp index 1b2454f1a..244f3dd18 100644 --- a/tslang/lib/TypeScript/LowerToAffineLoops.cpp +++ b/tslang/lib/TypeScript/LowerToAffineLoops.cpp @@ -1226,6 +1226,46 @@ struct BoundIndirectIndexAccessorOpLowering : public TsPatterngetParentRegion(); region != nullptr; region = region->getParentRegion()) + { + auto *owner = region->getParentOp(); + if (owner == nullptr) + { + break; + } + + if (auto tryOp = dyn_cast(owner)) + { + if (region == &tryOp.getCleanup()) + { + return true; + } + } + } + + return false; +} + struct TryOpLowering : public TsPattern { using TsPattern::TsPattern; @@ -1261,16 +1301,34 @@ struct TryOpLowering : public TsPattern auto i8PtrTy = mth.getOpaqueType(); // find catch var + // + // This region walk must stop at a nested `try`: a `try/catch` written inside a catch + // clause puts a second CatchOp in this region, and picking it up here means setting the + // RTTI type from the *inner* clause and pointing catchOpPtr at a catch that belongs to + // another try. That is a wrong type filter on this try's landing pad, which faults at + // run time in every memory model at every optimisation level (the debug assert below + // fires on it first). A nested try is lowered by its own application of this pattern, + // which finds its own catch there. + // + // Pre-order, because `skip()` only prunes the regions still to come - a post-order walk + // has already visited them by the time the callback sees the TryOp. Operation *catchOpPtr = nullptr; auto visitorCatchContinue = [&](Operation *op) { + if (op != tryOp.getOperation() && isa(op)) + { + return WalkResult::skip(); + } + if (auto catchOp = dyn_cast_or_null(op)) { rttih.setType(cast(catchOp.getCatchArg().getType()).getElementType()); assert(!catchOpPtr); catchOpPtr = op; } + + return WalkResult::advance(); }; - tryOp.getCatches().walk(visitorCatchContinue); + tryOp.getCatches().walk(visitorCatchContinue); // set TryOp -> child TryOp auto visitorTryOps = [&](Operation *op) { @@ -1378,6 +1436,22 @@ struct TryOpLowering : public TsPattern { tsContext->unwind[op] = catchesBlock; } + else if (auto throwOp = dyn_cast_or_null(op)) + { + // A throw leaves the catch clause just as abruptly as a return does, and + // owes the same end-of-catch. It cannot be recorded in `unwind` with the + // others: for a throw that map already means its invoke destination, and + // the finally handling below writes exactly that into it. + // + // Only when there is no finally, though. With one, that same handling + // turns this throw into an invoke into the finally block, and the finally + // is what ends the catch - ending it here as well runs it twice and + // breaks the unwind (51exceptions.ts is the case that proves it). + if (!finallyHasOps) + { + tsContext->leavesCatch.insert(op); + } + } }; auto it = catchesBlock; do @@ -1514,6 +1588,11 @@ struct TryOpLowering : public TsPattern { // TODO: check for nested ops for example in if block auto visitorCallOpContinue = [&](Operation *op) { + if (isInsideNestedCleanupRegion(op)) + { + return; + } + if (auto callOp = dyn_cast_or_null(op)) { tsContext->unwind[op] = landingBlock; @@ -1545,8 +1624,17 @@ struct TryOpLowering : public TsPattern ? (mlir::Value)rttih.typeInfoPtrValue(loc) : /*catch all*/ (mlir::Value)rewriter.create(loc, mth.getNullType()); + // A cleanup-only try (a `using` scope) nested inside another try in the same function + // has to hand the exception on to that enclosing landing pad once it has disposed, and + // on the Itanium path that is done by rethrowing rather than resuming - see the + // linuxHasCleanups block at the end of this method. That rethrow needs a catch-all pad, + // same as the nested-finally case beside it. + auto linuxCleanupOnlyChainsToParent = + linuxHasCleanups && !catchHasOps && !finallyHasOps && parentTryOpLandingPad; + mlir::Value catchAll; - if (parentTryOpLandingPad && finallyHasOps || linuxHasCleanups && rttih.hasType()) + if (parentTryOpLandingPad && finallyHasOps || linuxHasCleanups && rttih.hasType() || + linuxCleanupOnlyChainsToParent) { catchAll = (mlir::Value)rewriter.create(loc, mth.getNullType()); } @@ -1582,10 +1670,27 @@ struct TryOpLowering : public TsPattern rewriter.setInsertionPoint(cleanupBlockLast->getTerminator()); mlir::SmallVector unwindDests; - unwindDests.push_back(catchesBlock ? catchesBlock : finallyBlock); + // catchesBlock and finallyBlock both being null is a real case, not an oversight: + // a cleanup-only try (a `using` with no explicit catch/finally) has nowhere of its + // own to hand the unwind to. Leaving unwindDests empty here is what tells + // EndCleanupOp to resume unwinding instead of branching to a block that does not + // exist - the Linux cleanup-only case below already relies on the same fallback + // chain, catchesBlock -> finallyBlock -> parentTryOpLandingPad -> resume. + if (catchesBlock) + { + unwindDests.push_back(catchesBlock); + } + else if (finallyBlock) + { + unwindDests.push_back(finallyBlock); + } + else if (parentTryOpLandingPad) + { + unwindDests.push_back(parentTryOpLandingPad); + } auto resultOpCleanup = cast(cleanupBlockLast->getTerminator()); - rewriter.replaceOpWithNewOp(resultOpCleanup, landingPadCleanupOp, unwindDests); + rewriter.replaceOpWithNewOp(resultOpCleanup, landingPadCleanupOp, unwindDests); } mlir::Value cmpValue; @@ -1802,12 +1907,45 @@ struct TryOpLowering : public TsPattern rewriter.mergeBlocks(finallyBlock, cleanupBlockLast); } } + else if (linuxCleanupOnlyChainsToParent) + { + // cleanup-only try (a `using` scope) with another try around it in the same + // function. Resuming here would unwind straight past that enclosing try - its + // landing pad would be left with no predecessors at all and an exception the + // function does mean to catch would leave it instead, which is what + // 00using_nested_scopes.ts caught. + // + // The funclet path repairs this shape in Win32ExceptionPass by redirecting the + // unwind edge, but there is no equivalent on the Itanium path: a landingpad is + // only ever reachable as an invoke's unwind destination, so this cleanup cannot + // simply branch into the enclosing pad. What it can do is catch the exception, + // dispose, and throw it again with the enclosing pad as the unwind edge. That is + // exactly the shape the nested-finally case above already lowers to - catch-all + // pad, __cxa_begin_catch, the cleanup body, then a rethrow that unwinds to the + // parent - so this reuses it rather than inventing a second one. + // + // No EndCatchOp: the rethrow leaves through the unwind edge, so control never + // reaches an instruction after it. + rewriter.setInsertionPointToStart(cleanupBlock); + + auto landingPadCleanupOp = rewriter.create( + loc, rttih.getLandingPadType(), rewriter.getBoolAttr(false), ValueRange{catchAll}); + rewriter.create(loc, mth.getOpaqueType(), landingPadCleanupOp); + + rewriter.setInsertionPoint(cleanupBlockLast->getTerminator()); + auto nullVal = rewriter.create(loc, mth.getNullType()); + + auto resultOpCleanup = cast(cleanupBlockLast->getTerminator()); + auto throwOp = rewriter.replaceOpWithNewOp(resultOpCleanup, nullVal); + tsContext->unwind[throwOp] = parentTryOpLandingPad; + } else { - // cleanup-only try (e.g. lowered from a `using` declaration with no explicit - // catch/finally): the Windows path already sets up its own landing pad for this - // case unconditionally at cleanupHasOps&&isWindows above; mirror that here for - // Linux so cleanupBlockLast keeps a valid terminator instead of losing it. + // cleanup-only try at the top of its function: nothing encloses it, so once the + // cleanup has run the exception carries on out of the function. The Windows path + // sets up its own landing pad for this case unconditionally at + // cleanupHasOps&&isWindows above; mirror that here so cleanupBlockLast keeps a + // valid terminator instead of losing it. rewriter.setInsertionPointToStart(cleanupBlock); auto landingPadCleanupOp = rewriter.create( @@ -1815,14 +1953,9 @@ struct TryOpLowering : public TsPattern rewriter.create(loc); rewriter.setInsertionPoint(cleanupBlockLast->getTerminator()); - mlir::SmallVector unwindDests; - if (parentTryOpLandingPad) - { - unwindDests.push_back(parentTryOpLandingPad); - } - auto resultOpCleanup = cast(cleanupBlockLast->getTerminator()); - rewriter.replaceOpWithNewOp(resultOpCleanup, landingPadCleanupOp, unwindDests); + rewriter.replaceOpWithNewOp(resultOpCleanup, landingPadCleanupOp, + mlir::SmallVector{}); } } @@ -1940,6 +2073,17 @@ struct ThrowOpLowering : public TsPattern Location loc = throwOp.getLoc(); + // Throwing out of a catch clause has to end the active catch first, the same as a + // return, break or continue leaving one. Without it the catch region is left with no + // end marker at all - CutBlock below removes the one TryOpLowering placed before the + // terminator - and Win32ExceptionPass then picks an end for itself, splitting the + // block ahead of the throw and emitting the catchret before a call that still carries + // the funclet token. That IR reaches the backend and crashes it. + if (tsContext->leavesCatch.contains(throwOp.getOperation())) + { + rewriter.create(loc); + } + if (auto unwind = tsContext->unwind[throwOp]) { rewriter.replaceOpWithNewOp(throwOp, throwOp.getException(), unwind); @@ -2111,6 +2255,11 @@ struct CaptureOpLowering : public TsPattern mlir::Value allocTempStorage = rewriter.create( location, captureRefType, mlir::Value(), rewriter.getBoolAttr(inHeapMemory), rewriter.getIndexAttr(0)); + // `captured` here only means "allocate in the heap". Saying so keeps the box from being + // read as a captured variable's cell, which is the other reason a variable is heap + // allocated and the one that comes with a frame's reference. + allocTempStorage.getDefiningOp()->setAttr(CAPTURE_BOX_ATTR_NAME, rewriter.getUnitAttr()); + for (auto [index, val] : enumerate(captureOp.getCaptured())) { auto thisStoreFieldType = captureStoreType.getType(index); @@ -2334,7 +2483,7 @@ void AddTsAffineLegalOps(ConversionTarget &target) mlir_ts::AddressOfOp, mlir_ts::ArithmeticBinaryOp, mlir_ts::ArithmeticUnaryOp, mlir_ts::AssertOp, mlir_ts::CastOp, mlir_ts::ConstantOp, mlir_ts::ElementRefOp, mlir_ts::PointerOffsetRefOp, mlir_ts::FuncOp, mlir_ts::GlobalOp, mlir_ts::GlobalResultOp, mlir_ts::DefaultOp, mlir_ts::HasValueOp, mlir_ts::ValueOp, mlir_ts::ValueOrDefaultOp, mlir_ts::NullOp, mlir_ts::ParseFloatOp, mlir_ts::ParseIntOp, mlir_ts::IsNaNOp, - mlir_ts::PrintOp, mlir_ts::ConvertFOp, mlir_ts::SizeOfOp, mlir_ts::StoreOp, mlir_ts::SymbolRefOp, mlir_ts::LengthOfOp, mlir_ts::SetLengthOfOp, + mlir_ts::PrintOp, mlir_ts::ConvertFOp, mlir_ts::SizeOfOp, mlir_ts::TypeDescriptorOp, mlir_ts::RetainOp, mlir_ts::ReleaseOp, mlir_ts::RetainSlotOp, mlir_ts::ReleaseSlotOp, mlir_ts::RetainCellOp, mlir_ts::ReleaseCellOp, mlir_ts::StoreOp, mlir_ts::SymbolRefOp, mlir_ts::LengthOfOp, mlir_ts::SetLengthOfOp, mlir_ts::StringLengthOp, mlir_ts::SetStringLengthOp, mlir_ts::StringConcatOp, mlir_ts::StringCompareOp, mlir_ts::AnyCompareOp, mlir_ts::LoadOp, mlir_ts::LoadSaveOp, mlir_ts::NewOp, mlir_ts::CreateTupleOp, mlir_ts::DeconstructTupleOp, mlir_ts::CreateArrayOp, mlir_ts::NewEmptyArrayOp, mlir_ts::NewArrayOp, mlir_ts::DeleteOp, mlir_ts::PropertyRefOp, mlir_ts::InsertPropertyOp, diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index face1f7da..e5c9a3ab8 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -346,6 +346,165 @@ class IsNaNOpLowering : public TsLlvmPattern } }; +class TypeDescriptorOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::TypeDescriptorOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + TypeOfOpHelper toh(rewriter); + + auto descriptorType = op.getDescriptorType(); + auto name = toh.typeOfAsString(descriptorType); + if (name.empty()) + { + // TypeOfOpHelper::typeDescriptorValue only builds this op when the type has a + // name, so an empty one here means a type grew a descriptor without growing a + // typeOf name. + op.emitError("no 'typeof' name for type: ") << descriptorType; + return mlir::failure(); + } + + // generated first: the descriptor's initializer takes the routine's address, so the + // symbol has to exist before the global is built + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + auto releaseRoutineName = orl.getOrCreateReleaseRoutine(descriptorType); + auto retainRoutineName = orl.getOrCreateRetainRoutine(descriptorType); + + rewriter.replaceOp(op, ch.getOrCreateTypeDescriptorName(descriptorType, name, + TypeOfOpHelper::typeKindFromName(name), + releaseRoutineName, retainRoutineName)); + + return success(); + } +}; + +// Retain and Release lower to nothing at all unless the memory model is reference +// counting. That is what lets MLIRGen state ownership unconditionally: the ops carry the +// intent, and the model decides whether it costs anything. It also means the collected +// builds cannot be broken by where the ops are placed, only the counted ones can. +class RetainOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::RetainOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitRetainValue(op.getReference().getType(), transformed.getReference()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + +class ReleaseOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::ReleaseOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitReleaseValue(op.getReference().getType(), transformed.getReference()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + +// The slot-addressed forms. Same erasure rule, and erasing one of these takes the whole +// access with it - there is no load to leave behind in a collected build. +class RetainSlotOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::RetainSlotOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitRetainSlot(cast(op.getSlot().getType()).getElementType(), transformed.getSlot()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + +class ReleaseSlotOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::ReleaseSlotOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitReleaseSlot(cast(op.getSlot().getType()).getElementType(), transformed.getSlot()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + +// The cell-addressed forms. The slot is itself a heap block - a captured variable's storage - +// so these count owners of that block, where the two above count owners of the value in it. +class RetainCellOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::RetainCellOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitRetainCell(transformed.getSlot()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + +class ReleaseCellOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::ReleaseCellOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitReleaseCell(cast(op.getSlot().getType()).getElementType(), + transformed.getSlot()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + class SizeOfOpLowering : public TsLlvmPattern { public: @@ -455,6 +614,49 @@ class SetLengthOfOpLowering : public TsLlvmPattern rewriter.create(loc, allocated, currentPtrPtr); + // `arr.length = n` on a grown array exposes slots the allocator has not written, and a + // store into one gives up what the slot held first (`isOwnedElementSlot`) - so the + // release reads whatever was last in that memory. `result.length = this.length` followed + // by `result[i] = ..` is exactly the default library's `Array.map`, which is why + // `arrS.map(e => e + "_")` crashed about one run in three (§9.37). + // + // Only where an element owns something, and only under -mm=rc: nothing reads an + // unwritten slot in the other models, and the memset is not free. + if (tsLlvmContext->compileOptions.isRefCounted()) + { + MLIRTypeHelper mth(rewriter.getContext(), tsLlvmContext->compileOptions); + if (mth.ownsHeapMemory(loc, elementType)) + { + auto oldBytes = rewriter.create(loc, th.getIndexType(), + ValueRange{sizeOfTypeAsIndexType, + rewriter.create( + loc, th.getIndexType(), countAsIndexType)}); + auto grew = rewriter.create(loc, mlir::index::IndexCmpPredicate::UGT, + multSizeOfTypeValue, oldBytes); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + auto *zeroBlock = rewriter.createBlock(continuationBlock); + + rewriter.setInsertionPointToEnd(zeroBlock); + auto tailStart = rewriter.create(loc, ptrType, th.getI8Type(), allocated, + ValueRange{rewriter.create( + loc, llvmIndexType, oldBytes)}); + auto tailBytes = rewriter.create(loc, th.getIndexType(), + multSizeOfTypeValue, oldBytes); + rewriter.create( + loc, tailStart, + rewriter.create(loc, th.getI8Type(), rewriter.getI8IntegerAttr(0)), + rewriter.create(loc, llvmIndexType, tailBytes), /*isVolatile=*/false); + rewriter.create(loc, ValueRange{}, continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + rewriter.create(loc, grew, zeroBlock, continuationBlock); + + rewriter.setInsertionPointToStart(continuationBlock); + } + } + auto newCountAsLLVMType = rewriter.create(loc, llvmIndexType, newCountAsIndexType); rewriter.create(loc, newCountAsLLVMType, countAsIndexTypePtr); @@ -854,21 +1056,18 @@ class AnyCompareOpLowering : public TsLlvmPattern // typeOfAsString reports concrete-width tags ("s32"/"s64"/...) for integer // literals and only uses "number" for float-typed values (see - // TypeOfOpHelper::typeOfAsString) -- so "is this any numeric" must check the - // realistic set of concrete tags a `number`-inferred literal can carry, not - // just the literal string "number". - auto isNumericTag = [&](mlir::Value tag) { - mlir::Value result = isTag(tag, "number"); - for (auto name : {"s32", "s64", "u32", "u64", "i32", "i64", "f32", "f64"}) - { - result = rewriter.create(loc, result, isTag(tag, name)); - } - return result; - }; + // TypeOfOpHelper::typeOfAsString), so "is this any numeric" cannot just test the + // name "number". The descriptor behind every tag carries the category directly, so + // this is one load and one compare rather than a chain of strcmps -- and it covers + // every numeric width rather than the nine that were spelled out here before. + TypeDescriptorLogic tdl(rewriter, tch, loc); + auto isKind = [&](mlir::Value tag, int kind) { return tdl.isKind(tag, kind); }; // unbox a numeric `any` (whatever its concrete boxed width/signedness) into // a normalized f64 for comparison, dispatching on the exact tag reported at - // box time so we read back the same width that was stored. + // box time so we read back the same width that was stored. This one stays + // name-based: the descriptor's kind says "numeric", not which width, and the + // width is what decides how many bytes to read back. auto unboxNumericAsF64 = [&](mlir::Value numberSideAny, mlir::Value numberTag) { auto asF64 = [&](mlir::Type storedTy) { auto raw = al.UnboxAny(numberSideAny, tch.convertType(storedTy)); @@ -923,12 +1122,12 @@ class AnyCompareOpLowering : public TsLlvmPattern return compareAsString(coercedStr, strVal); }; - auto tag1IsNumber = isNumericTag(tag1); - auto tag1IsString = isTag(tag1, "string"); - auto tag1IsBoolean = isTag(tag1, "boolean"); - auto tag2IsNumber = isNumericTag(tag2); - auto tag2IsString = isTag(tag2, "string"); - auto tag2IsBoolean = isTag(tag2, "boolean"); + auto tag1IsNumber = isKind(tag1, TYPE_KIND_NUMBER); + auto tag1IsString = isKind(tag1, TYPE_KIND_STRING); + auto tag1IsBoolean = isKind(tag1, TYPE_KIND_BOOLEAN); + auto tag2IsNumber = isKind(tag2, TYPE_KIND_NUMBER); + auto tag2IsString = isKind(tag2, TYPE_KIND_STRING); + auto tag2IsBoolean = isKind(tag2, TYPE_KIND_BOOLEAN); auto op1IsNumberOp2IsString = rewriter.create(loc, tag1IsNumber, tag2IsString); auto op1IsStringOp2IsNumber = rewriter.create(loc, tag1IsString, tag2IsNumber); @@ -1247,6 +1446,22 @@ class UndefOpLowering : public TsLlvmPattern } TypeConverterHelper tch(getTypeConverter()); + + // `undefined` materialised as a value of a type that owns heap memory has to be null + // rather than undef: under reference counting whoever receives it retains it, and + // reaching an undef pointer's block header is undefined behaviour. An iterator's final + // `{ value: undefined, done: true }` is built exactly this way, and the caller retains + // the result before it looks at `done`. + if (tsLlvmContext->compileOptions.isRefCounted()) + { + MLIRTypeHelper mth(rewriter.getContext(), tsLlvmContext->compileOptions); + if (mth.ownsHeapMemory(op.getLoc(), op.getType())) + { + rewriter.replaceOpWithNewOp(op, tch.convertType(op.getType())); + return success(); + } + } + rewriter.replaceOpWithNewOp(op, tch.convertType(op.getType())); return success(); } @@ -2096,6 +2311,17 @@ struct VariableOpLowering : public TsLlvmPattern { allocated = ch.MemoryAlloc(storageType); + + // A captured variable's storage is a heap block - a cell - shared by the frame that + // declared it and by every capture box that captured it. Unlike a value block, which + // is born unowned because a receiver is about to take it (§9.24), a cell is born + // owned: the frame is its first owner, and the frame's scope exit is what gives that + // reference back. A box is the one heap variable with no frame owner, and says so. + if (tsLlvmContext->compileOptions.isRefCounted() && !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME)) + { + OwnershipRoutineLogic orl(varOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitRetainCell(allocated); + } } #ifdef GC_ENABLE @@ -2131,6 +2357,28 @@ struct VariableOpLowering : public TsLlvmPattern } auto value = transformed.getInitializer(); + auto isUnwrittenCell = isCaptured && !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME); + if (!value && tsLlvmContext->compileOptions.isRefCounted() && + (varOp->hasAttr(OWNED_LOCAL_ATTR_NAME) || isUnwrittenCell)) + { + // An owned local with no initializer here is one whose storage was hoisted out in + // front of a TryOp; its initializing store stayed behind at the declaration. The + // unwind edge can reach the cleanup region before that store runs, and the release + // waiting there reads whatever the frame happened to hold. Null is the one value + // the release routines treat as nothing to do, so the slot starts as null. + // + // A cell needs it for the plainer reason that it is a *heap* block, so what it holds + // before its first store is whatever the allocator last had there. `let x: string;` + // captured by a closure that only assigns on a path never taken is the case: the + // scope exit releases the cell, the cell releases its contents, and the contents were + // never written. Not a corner - it is any captured declaration without an + // initializer, which an owned local can never be (§9.36). + // + // Only under -mm=rc: nothing reads the slot before its store in any other model, and + // a collected build is meant to come out of this step byte-identical. + rewriter.create(location, rewriter.create(location, storageType), allocated); + } + if (value) { rewriter.create(location, value, allocated); @@ -2141,6 +2389,18 @@ struct VariableOpLowering : public TsLlvmPattern rewriter.create(location, value, varInfo); } #endif + + // A cell owns the value in it: giving up the last reference to the cell releases + // what it holds (`emitReleaseCell`), and every store into one hands the count over + // (`isOwningSlot`). So the first value has to be taken too, unless the frame has + // already taken it - which is what an owned local's mark says, and what a captured + // parameter's storage is precisely missing, the argument being the caller's. + if (isCaptured && tsLlvmContext->compileOptions.isRefCounted() && + !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME) && !varOp->hasAttr(OWNED_LOCAL_ATTR_NAME)) + { + OwnershipRoutineLogic orl(varOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitRetainSlot(referenceType.getElementType(), allocated); + } } rewriter.replaceOp(varOp, ValueRange{allocated}); @@ -2865,10 +3125,40 @@ struct ArraySpliceOpLowering : public TsLlvmPattern auto decSizeAsIndexType = spliceOp.getDeleteCount(); auto startIndexAsLLVMType = rewriter.create(loc, llvmIndexType, startIndexAsIndexType); - auto decSizeAsLLVMType = rewriter.create(loc, llvmIndexType, decSizeAsIndexType); + mlir::Value decSizeAsLLVMType = rewriter.create(loc, llvmIndexType, decSizeAsIndexType); auto incSizeAsLLVMType = clh.createIndexConstantOf(llvmIndexType, transformed.getItems().size()); + // Give back what the removed elements were holding, before anything moves or frees them. + // + // `splice` memmoves the tail over the deleted range and reallocs; the references those + // slots held are simply overwritten, so under `-mm=rc` every element it removes leaked. + // Measured on a loop that splices two of three boxed strings away: 16.4 MB against 4.1 + // for the same program without the splice. See §9.74. + // + // This runs on `currentPtr` and before `conditionalExpressionLowering` below, which is + // the only correct place: the growing branch reallocs *first*, and a realloc may move the + // block, so releasing afterwards would read the deleted elements through a stale pointer. + // + // The delete count is first clamped to what is actually in the array, which JavaScript's + // `splice` also does ("if greater than the number of elements after start, then all of + // the elements from start onwards will be deleted"). It was not clamped here, and the + // subtraction below then underflowed an unsigned index: `["p","q"].splice(1, 10)` asked + // `memmove` for about 2^64 bytes and faulted. That reproduces under every memory model + // and long predates any of this - it simply had to be settled before releasing anything, + // since a release loop walking off the end reads freed memory rather than merely + // computing a wrong size. + auto availableAsLLVMType = + rewriter.create(loc, llvmIndexType, ValueRange{countAsIndexType, startIndexAsLLVMType}); + auto deleteFits = + rewriter.create(loc, LLVM::ICmpPredicate::ule, decSizeAsLLVMType, availableAsLLVMType); + decSizeAsLLVMType = rewriter.create(loc, deleteFits, decSizeAsLLVMType, availableAsLLVMType); + + { + OwnershipRoutineLogic orl(spliceOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitReleaseArrayElements(elementType, currentPtr, startIndexAsLLVMType, decSizeAsLLVMType); + } + // Keep all arithmetic in the already-LLVM-converted domain (llvmIndexType), matching // every sibling array-mutation lowering (ArrayPushOp/ArrayUnshiftOp/ArrayShiftOp) -- // mlir::index::*Op ops require genuinely `index`-typed operands, but countAsIndexType @@ -3017,6 +3307,18 @@ struct DeleteOpLowering : public TsLlvmPattern { + // Under reference counting `delete` drops a reference rather than freeing outright: + // the object goes only if this was the last one, and what it owns is released with it. + // That also keeps `delete` off an immortal block, which a bare free would not. + if (tsLlvmContext->compileOptions.isRefCounted()) + { + OwnershipRoutineLogic orl(deleteOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitReleaseValue(deleteOp.getReference().getType(), transformed.getReference()); + + rewriter.eraseOp(deleteOp); + return mlir::success(); + } + LLVMCodeHelper ch(deleteOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); if (mlir::failed(ch.MemoryFree(transformed.getReference()))) @@ -4760,6 +5062,12 @@ struct EndCleanupOpLowering : public TsLlvmPattern CodeLogicHelper clh(endCleanupOp, rewriter); + // getUnwindDest() is deliberately not read here, unlike the windows lowering above. A + // landingpad is only reachable as an invoke's unwind destination, so there is no way to + // hand the exception to another pad from here - resume is the only exit. TryOpLowering + // knows this and never gives an EndCleanupOp an unwind destination on this path: a + // cleanup that does have an enclosing pad to reach is lowered to a catch-all pad and a + // rethrow instead (linuxCleanupOnlyChainsToParent). rewriter.replaceOpWithNewOp(endCleanupOp, transformed.getLandingPad()); auto terminator = rewriter.getInsertionBlock()->getTerminator(); @@ -5010,6 +5318,46 @@ struct NewInterfaceOpLowering : public TsLlvmPattern { using TsLlvmPattern::TsLlvmPattern; + // The runtime type tag for whatever `this` points at, to go in INTERFACE_TYPE_INDEX. + // + // Null when that type owns no heap memory - which covers `null` and `undefined` cast to + // an interface, where `this` is a null pointer and there would be nothing to release + // anyway. A null tag states that positively, the same way a null descriptor slot does. + // + // The descriptor this returns is keyed by the concrete type, not by the `typeof` name, so + // two object literals of different shapes get their own records rather than sharing the + // one every "object" would otherwise map to. + mlir::Value getTypeTag(mlir_ts::NewInterfaceOp newInterfaceOp, ConversionPatternRewriter &rewriter) const + { + TypeHelper th(rewriter); + auto loc = newInterfaceOp.getLoc(); + auto thisType = newInterfaceOp.getThisVal().getType(); + + MLIRTypeHelper mth(rewriter.getContext(), tsLlvmContext->compileOptions); + if (!mth.ownsHeapMemory(loc, thisType)) + { + return rewriter.create(loc, th.getPtrType()); + } + + TypeOfOpHelper toh(rewriter); + auto name = toh.typeOfAsString(thisType); + if (name.empty()) + { + // an interface over a type with no `typeof` name: nothing to key a descriptor on, + // so it goes untracked and leaks rather than guessing at a record + return rewriter.create(loc, th.getPtrType()); + } + + // generated first: the descriptor's initializer takes their addresses + OwnershipRoutineLogic orl(newInterfaceOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + auto releaseRoutineName = orl.getOrCreateReleaseRoutine(thisType); + auto retainRoutineName = orl.getOrCreateRetainRoutine(thisType); + + LLVMCodeHelper ch(newInterfaceOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + return ch.getOrCreateTypeDescriptorName(thisType, name, TypeOfOpHelper::typeKindFromName(name), + releaseRoutineName, retainRoutineName); + } + LogicalResult matchAndRewrite(mlir_ts::NewInterfaceOp newInterfaceOp, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { @@ -5029,7 +5377,14 @@ struct NewInterfaceOpLowering : public TsLlvmPattern auto structVal3 = rewriter.create(loc, structVal2, transformed.getThisVal(), MLIRHelper::getStructIndex(rewriter, THIS_VALUE_INDEX)); - rewriter.replaceOp(newInterfaceOp, ValueRange{structVal3}); + // Every interface value in the program is built here - a cast from a class, from an + // object literal, and even `null`/`undefined` as an interface all end up at this op + // (CastLogicHelper) - which is what makes the type tag safe to rely on: there is no + // other way to produce an interface value with the slot left undefined. + auto structVal4 = rewriter.create(loc, structVal3, getTypeTag(newInterfaceOp, rewriter), + MLIRHelper::getStructIndex(rewriter, INTERFACE_TYPE_INDEX)); + + rewriter.replaceOp(newInterfaceOp, ValueRange{structVal4}); return success(); } @@ -5264,6 +5619,59 @@ struct CreateBoundFunctionOpLowering : public TsLlvmPattern::TsLlvmPattern; + // The runtime type tag of the capture box, for CLOSURE_TYPE_INDEX. + // + // Null unless MLIRGen marked this closure as owning its `this` - a bound method's receiver + // belongs to whoever holds the object, and giving the closure a tag for it would have `obj.m` + // take a reference to `obj` and hand it back when the bound value dies. + // + // The box arrives as a `ref` to its tuple, which owns nothing by itself (a reference into + // storage is not ownership). What has to be released is the block, so the tag names the + // ObjectType over that tuple: its routine decrefs, releases whatever the box holds, and + // frees - which is exactly what a capture box needs. + mlir::Value getCaptureTypeTag(mlir_ts::CreateBoundFunctionOp createBoundFunctionOp, + ConversionPatternRewriter &rewriter) const + { + TypeHelper th(rewriter); + auto loc = createBoundFunctionOp.getLoc(); + auto nullTag = [&]() -> mlir::Value { return rewriter.create(loc, th.getPtrType()); }; + + if (!createBoundFunctionOp->hasAttr(OWNS_CAPTURE_ATTR_NAME)) + { + return nullTag(); + } + + auto captureRefType = dyn_cast(createBoundFunctionOp.getThisVal().getType()); + if (!captureRefType || !isa(captureRefType.getElementType())) + { + return nullTag(); + } + + auto boxType = mlir_ts::ObjectType::get(captureRefType.getElementType()); + + TypeOfOpHelper toh(rewriter); + auto name = toh.typeOfAsString(boxType); + if (name.empty()) + { + return nullTag(); + } + + // generated first: the descriptor's initializer takes their addresses. + // + // The box has routines of its own rather than an object's, because a captured + // variable's field holds the address of that variable's *cell* - a heap block the box + // co-owns - and a RefType field owns nothing anywhere else in the compiler. Keyed by + // the capture's `ref>` for the same reason, so neither the routines nor the + // descriptor can be reused for a plain object of the same shape. + OwnershipRoutineLogic orl(createBoundFunctionOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + auto releaseRoutineName = orl.getOrCreateCaptureBoxReleaseRoutine(captureRefType); + auto retainRoutineName = orl.getOrCreateCaptureBoxRetainRoutine(captureRefType); + + LLVMCodeHelper ch(createBoundFunctionOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + return ch.getOrCreateTypeDescriptorName(captureRefType, name, TypeOfOpHelper::typeKindFromName(name), + releaseRoutineName, retainRoutineName); + } + LogicalResult matchAndRewrite(mlir_ts::CreateBoundFunctionOp createBoundFunctionOp, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { @@ -5293,7 +5701,13 @@ struct CreateBoundFunctionOpLowering : public TsLlvmPattern(loc, structVal2, transformed.getThisVal(), MLIRHelper::getStructIndex(rewriter, THIS_VALUE_INDEX)); - rewriter.replaceOp(createBoundFunctionOp, ValueRange{structVal3}); + // Every bound or hybrid function value in the program is built here, which is what makes + // the tag safe to read - the same property NewInterface has (§9.31). + auto structVal4 = rewriter.create( + loc, structVal3, getCaptureTypeTag(createBoundFunctionOp, rewriter), + MLIRHelper::getStructIndex(rewriter, CLOSURE_TYPE_INDEX)); + + rewriter.replaceOp(createBoundFunctionOp, ValueRange{structVal4}); return success(); } @@ -6171,6 +6585,8 @@ static void populateTypeScriptConversionPatterns(LLVMTypeConverter &converter, m SmallVector llvmStructType; llvmStructType.push_back(LLVM::LLVMPointerType::get(m.getContext())); llvmStructType.push_back(LLVM::LLVMPointerType::get(m.getContext())); + // type tag of the capture box, when this value is a closure - see CLOSURE_TYPE_INDEX + llvmStructType.push_back(LLVM::LLVMPointerType::get(m.getContext())); return LLVM::LLVMStructType::getLiteral(type.getContext(), llvmStructType, false); }); @@ -6178,6 +6594,8 @@ static void populateTypeScriptConversionPatterns(LLVMTypeConverter &converter, m SmallVector llvmStructType; llvmStructType.push_back(LLVM::LLVMPointerType::get(m.getContext())); llvmStructType.push_back(LLVM::LLVMPointerType::get(m.getContext())); + // as above + llvmStructType.push_back(LLVM::LLVMPointerType::get(m.getContext())); return LLVM::LLVMStructType::getLiteral(type.getContext(), llvmStructType, false); }); @@ -6248,6 +6666,8 @@ static void populateTypeScriptConversionPatterns(LLVMTypeConverter &converter, m rtInterfaceType.push_back(th.getPtrType()); // this rtInterfaceType.push_back(th.getPtrType()); + // runtime type tag of what `this` points at - see INTERFACE_TYPE_INDEX + rtInterfaceType.push_back(th.getPtrType()); return LLVM::LLVMStructType::getLiteral(type.getContext(), rtInterfaceType, false); }); @@ -6719,6 +7139,79 @@ static LogicalResult cleanupUnrealizedConversionCast(mlir::ModuleOp &module) return success(); } +// A TypeScript `main` returning nothing lowers to `void @main()`, and the C runtime that calls it +// reads an exit code out of the return register regardless. Whatever the last instruction happened +// to leave there became the process's exit code: zero under `gc` and `none` by luck, and 1 under +// `rc`, where the last thing `main` does is give back a reference. So an ahead-of-time `rc` build +// of `function main() { print(1); }` printed the right answer and told the shell it had failed. +// +// Give the entry point the signature its caller assumes: `i32 @main()` returning 0. Only for a +// `main` that returns nothing and takes nothing - a `main` returning a value is left alone here, +// and is a separate question (today it lowers to `double @main()`, which is wrong in the same way +// and for the same reason). +// +// The test for "will be linked into an executable" is not `isExecutable`: that is only true for +// `--emit=exe`, and everything that links a program here compiles with `--emit=obj` and calls the +// linker itself, which is how this went unnoticed once already. A JIT run has no C runtime reading +// a return register, and a DLL's `main` is not an entry point and may be something an importer +// resolves, so those two are the exclusions. +static void giveEntryPointAnExitCode(mlir::ModuleOp m, CompileOptions &compileOptions) +{ + if (compileOptions.isJit || compileOptions.isDLL) + { + return; + } + + auto funcOp = dyn_cast_or_null(m.lookupSymbol(MAIN_ENTRY_NAME)); + if (!funcOp || funcOp.getBody().empty()) + { + return; + } + + auto funcType = funcOp.getFunctionType(); + if (funcType.getNumParams() != 0 || !isa(funcType.getReturnType())) + { + return; + } + + // A call inside the module would be left calling a signature that no longer matches. Nothing + // generates one today - `main` is the entry point, and top-level code that needs to run before + // it becomes a global constructor - but a silent type mismatch is not the failure to risk. + auto hasInternalCaller = false; + m.walk([&](LLVM::CallOp callOp) { + if (callOp.getCallee() && callOp.getCallee().value() == MAIN_ENTRY_NAME) + { + hasInternalCaller = true; + } + }); + + if (hasInternalCaller) + { + return; + } + + mlir::OpBuilder builder(funcOp); + auto i32Type = builder.getI32Type(); + funcOp.setFunctionType(LLVM::LLVMFunctionType::get(i32Type, {}, false)); + + SmallVector returns; + funcOp.walk([&](LLVM::ReturnOp returnOp) { + if (returnOp.getNumOperands() == 0) + { + returns.push_back(returnOp); + } + }); + + for (auto returnOp : returns) + { + mlir::OpBuilder returnBuilder(returnOp); + auto zero = returnBuilder.create(returnOp.getLoc(), i32Type, + returnBuilder.getI32IntegerAttr(0)); + returnBuilder.create(returnOp.getLoc(), mlir::ValueRange{zero}); + returnOp.erase(); + } +} + void TypeScriptToLLVMLoweringPass::runOnOperation() { auto m = getOperation(); @@ -6781,7 +7274,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() PointerOffsetRefOpLowering, LogicalBinaryOpLowering, NullOpLowering, NewOpLowering, CreateTupleOpLowering, DeconstructTupleOpLowering, CreateArrayOpLowering, NewEmptyArrayOpLowering, NewArrayOpLowering, ArrayPushOpLowering, ArrayPopOpLowering, ArrayUnshiftOpLowering, ArrayShiftOpLowering, ArraySpliceOpLowering, ArrayViewOpLowering, DeleteOpLowering, - ParseFloatOpLowering, ParseIntOpLowering, IsNaNOpLowering, PrintOpLowering, ConvertFOpLowering, StoreOpLowering, SizeOfOpLowering, + ParseFloatOpLowering, ParseIntOpLowering, IsNaNOpLowering, PrintOpLowering, ConvertFOpLowering, StoreOpLowering, SizeOfOpLowering, TypeDescriptorOpLowering, RetainOpLowering, ReleaseOpLowering, RetainSlotOpLowering, ReleaseSlotOpLowering, RetainCellOpLowering, ReleaseCellOpLowering, InsertPropertyOpLowering, LengthOfOpLowering, SetLengthOfOpLowering, StringLengthOpLowering, SetStringLengthOpLowering, StringConcatOpLowering, StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering, LoadSaveValueLowering, ThrowUnwindOpLowering, ThrowCallOpLowering, VariableOpLowering, DebugVariableOpLowering, AllocaOpLowering, InvokeOpLowering, @@ -6847,6 +7340,8 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() cleanupUnrealizedConversionCast(m); + giveEntryPointAnExitCode(m, tsContext.compileOptions); + LLVM_DEBUG(llvm::dbgs() << "\n!! AFTER DUMP: \n" << m << "\n";); LLVM_DEBUG(verifyModule(m);); diff --git a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp index 96f4b2676..6e6a4a4dc 100644 --- a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp +++ b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp @@ -1751,7 +1751,22 @@ namespace mlirgen if (genContext.dummyRun) { - // just to cut a lot of calls + // The discovery pass doesn't resolve the constructor - that is the "cut a lot of + // calls" shortcut below - but it still has to WALK the constructor arguments. + // Discovery is what registers a lambda's outer variables (see + // resolveIdentifierAsVariable, which fills passResult->outerVariables), so an + // expression it never visits contributes no captures. A variable read only inside + // `new C(...)`'s arguments was therefore missed, and the real pass then emitted the + // read against the enclosing function's own value from inside the lambda's body - + // "'ts.Load' op using value defined outside the region", since ts.Func is + // IsolatedFromAbove. Errors here are the shortcut's own (no constructor, no + // receiver types); discovery is best-effort, so ignore them and keep the ops that + // the walk produced - they land in the throwaway dummy function. + for (auto argument : arguments) + { + mlirGen(argument, genContext); + } + newOp = builder.create(location, classInfo->classType, builder.getBoolAttr(false)); return newOp; } @@ -1827,7 +1842,7 @@ namespace mlirgen { mlir::Value newOp; #if ENABLE_TYPED_GC - auto enabledGC = !compileOptions.disableGC; + auto enabledGC = compileOptions.needsGCRuntime(); if (enabledGC && !stackAlloc) { auto typeDescrType = builder.getI64Type(); @@ -1901,6 +1916,17 @@ namespace mlirgen auto resultCall = mlirGenCallExpression(location, newFuncRef, {}, emptyOperands, genContext); EXIT_IF_FAILED_OR_NO_VALUE(resultCall) auto newOp = V(resultCall); + + // `C..new` is generated by this compiler, so it goes through the return path that + // retains its result (§9.24) and the instance arrives already owned. Say so here, + // where the callee is known, rather than letting anything downstream guess from the + // shape of the call - a runtime helper or an imported function returns a heap value + // with no retain at all, and mistaking one for this would free live memory. + if (auto *definingOp = newOp.getDefiningOp()) + { + definingOp->setAttr(OWNED_RESULT_ATTR_NAME, builder.getUnitAttr()); + } + return newOp; } #endif diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index 3daae95cd..31148d7f9 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -650,7 +650,32 @@ namespace mlirgen return mlir::failure(); } - return V(builder.create(location, type, value)); + // Boxing into `any` makes the box an owner of what it now holds, and it has to take a + // reference to say so: an `any` releases its payload through the type tag beside it when + // the box dies (`buildBody`, AnyType). Nothing took one. The payload was copied in, and + // where it arrived carrying a reference of its own - a closure, whose `__owned_result` + // is the box of captured variables it was built over - §9.30 gave that reference back at + // the end of the block and left the `any` pointing at freed memory. Reachable wherever + // the boxed value outlives the block that boxed it, `fns.push(qux2)` into an `any[]` + // being the shape that found it (§9.36). + if (isa(type)) + { + mlirGenRetainCaptured(location, mlir::ValueRange{value}); + } + + auto castResult = builder.create(location, type, value); + + // Printing a number into a string allocates one, and nothing was giving it back: in + // `"s" + k` the conversion's result is read by the concatenation and then forgotten - + // not received by anything, so not a receiver's to release, and not marked, so not + // §9.30's either. It is the plainest allocation in the language and it leaked every + // time (§9.37). + if (isa(type) && castToStringAllocates(valueType)) + { + markFreshStringOwned(location, castResult); + } + + return V(castResult); } mlir::LogicalResult MLIRGenImpl::verifyCastPreconditions(mlir::Location location, mlir::Type type, mlir::Type valueType, bool disableStrictNullCheck) @@ -1669,6 +1694,12 @@ namespace mlirgen // convert Tuple to Object auto objType = mlir_ts::ObjectType::get(tupleType); auto valueAddr = builder.create(location, mlir_ts::ValueRefType::get(tupleType), builder.getBoolAttr(false)); + + // this block releases what its fields hold when it dies, so it has to take a reference + // to each of them first - the same debt an array literal's data block carries (§9.21), + // and inert until an interface became an owner (§9.31), which is why it went unnoticed + mlirGenRetainCaptured(location, mlir::ValueRange{inEffective}); + builder.create(location, inEffective, valueAddr); auto inCasted = builder.create(location, objType, valueAddr); @@ -1709,6 +1740,10 @@ namespace mlirgen CAST_A(unboxed, location, newInterfaceTupleType, in, genContext); auto valueAddr = builder.create(location, mlir_ts::ValueRefType::get(newInterfaceTupleType), builder.getBoolAttr(false)); + + // as in castTupleToInterface: the clone's block owns what it holds + mlirGenRetainCaptured(location, mlir::ValueRange{unboxed}); + builder.create(location, unboxed, valueAddr); effectiveObjType = mlir_ts::ObjectType::get(newInterfaceTupleType); inEffective = builder.create(location, effectiveObjType, valueAddr); @@ -1724,8 +1759,23 @@ namespace mlirgen LLVM_DEBUG(llvm::dbgs() << "\n!!" << "@ created interface:" << createdInterfaceVTableForObject << "\n";); - return V(builder.create(location, - mlir::TypeRange{interfaceInfo->interfaceType}, inEffective, createdInterfaceVTableForObject)); + mlir::Value interfaceValue = builder.create(location, + mlir::TypeRange{interfaceInfo->interfaceType}, inEffective, createdInterfaceVTableForObject); + + // An interface made from an object is a reference of its own, and on the paths that + // reach here the object is very often a block allocated a few lines above for exactly + // this cast - so nothing else holds it, and nothing else will give it back. + // + // Handing the result a reference is what lets every receiver treat it uniformly: a + // local consumes it (§9.25), a return forwards it, and one that goes to an argument and + // is then dropped - `trace({ start: pos, dir: rd }, scene)`, which is what this cast is + // mostly used for - is released at the end of the block that made it (§9.30). Without + // the +1 that last shape has no owner at all and neither retains nor releases, which is + // where the whole of raytrace's remaining leak sat. + builder.create(location, interfaceValue); + interfaceValue.getDefiningOp()->setAttr(OWNED_RESULT_ATTR_NAME, builder.getUnitAttr()); + + return V(interfaceValue); } mlir_ts::CreateBoundFunctionOp MLIRGenImpl::createBoundMethodFromExtensionMethod(mlir::Location location, mlir_ts::CreateExtensionFunctionOp createExtentionFunction) diff --git a/tslang/lib/TypeScript/MLIRGenClasses.cpp b/tslang/lib/TypeScript/MLIRGenClasses.cpp index 4b8356289..0371abee2 100644 --- a/tslang/lib/TypeScript/MLIRGenClasses.cpp +++ b/tslang/lib/TypeScript/MLIRGenClasses.cpp @@ -192,7 +192,7 @@ namespace mlirgen #endif #if ENABLE_TYPED_GC - auto enabledGC = !compileOptions.disableGC; + auto enabledGC = compileOptions.needsGCRuntime(); if (enabledGC && !newClassPtr->isStatic) { mlirGenClassTypeBitmap(location, newClassPtr, classGenContext); diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index 1e86dda07..4b6c8aadd 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -415,10 +415,36 @@ namespace mlirgen auto location = stripMetadata(loc(awaitExpressionAST)); auto resultType = evaluate(awaitExpressionAST->expression, genContext); + if (resultType && isa(resultType)) + { + resultType = mlir::Type(); + } + + // The result travels through a slot in the awaiting function rather than through + // `!async.value`. MLIR's async-to-LLVM conversion runs before the TypeScript types are + // lowered (see transform.cpp - createConvertAsyncToLLVMPass, then createLowerToLLVMPass), + // and it converts an async value's payload with its own LLVMTypeConverter, which knows + // nothing about this dialect. So a payload of any TypeScript type - `number`, `string`, + // `boolean`, a class, an array - failed with "failed to legalize operation + // 'async.runtime.load'", and only awaits whose payload happened to be a builtin type + // (`i32`, `i64`, `f32`) ever compiled. Section 9.56. + // + // A token carries no payload, so nothing has to convert. The awaited body writes into the + // slot, `async.await` on the token is what orders that write before the read, and the + // outlining pass passes the slot in as an argument like any other value the region uses + // from above. + mlir::Value resultSlot; + if (resultType) + { + resultSlot = builder.create(location, mlir_ts::RefType::get(resultType), + mlir::Value(), builder.getBoolAttr(false), + builder.getIndexAttr(0)); + } ValueOrLogicalResult result(mlir::failure()); + auto slotOwnsResult = false; auto asyncExecOp = builder.create( - location, resultType ? mlir::TypeRange{resultType} : mlir::TypeRange(), mlir::ValueRange{}, + location, mlir::TypeRange(), mlir::ValueRange{}, mlir::ValueRange{}, [&](mlir::OpBuilder &builder, mlir::Location location, mlir::ValueRange values) { DITableScopeT debugAsyncCodeScope(debugScope); MLIRDebugInfoHelper mdi(builder, debugScope); @@ -431,26 +457,60 @@ namespace mlirgen if (result) { auto value = V(result); - if (value) + // No cast: `resultType` is what `evaluate` said this same expression produces, + // and the yield that used to carry it had to match the execute's result type + // for the op to verify at all. + if (value && resultSlot) { - builder.create(location, mlir::ValueRange{value}); - } - else - { - builder.create(location, mlir::ValueRange{}); + // Under reference counting the slot is what keeps the result alive across + // the await. Everything the awaited expression produced is a temporary of + // the region's own block, so without this the value is released the moment + // the region ends - before the awaiting function has read the slot. It read + // correctly at -O0 and garbage at -O3, which is only ever a matter of what + // reused the block first. + // + // Same two cases as a local's declaration: a value that already carries a + // reference hands it over, anything else is retained. Either way the slot + // is the owner from here, and the awaiting scope gives that reference back. + if (compileOptions.isRefCounted() && mth.ownsHeapMemory(location, resultType)) + { + if (producesOwnedReference(value)) + { + consumeOwnedReference(value); + } + else + { + builder.create(location, value); + } + + slotOwnsResult = true; + } + + builder.create(location, value, resultSlot); } + + builder.create(location, mlir::ValueRange{}); } }); EXIT_IF_FAILED_OR_NO_VALUE(result) - if (resultType) + // Registered only now, and only if the store above actually happened: an owned slot is + // released at scope exit whatever is in it, and a slot nothing wrote holds whatever the + // frame held before. The attributes say the retain is elsewhere - inside the region, beside + // the store - so the verifier does not go looking for one at the declaration. + if (slotOwnsResult && genContext.ownedVars != nullptr) { - auto asyncAwaitOp = builder.create(location, asyncExecOp.getResults().back()); - return asyncAwaitOp.getResult(); + auto varOp = resultSlot.getDefiningOp(); + varOp->setAttr(OWNED_LOCAL_ATTR_NAME, builder.getUnitAttr()); + varOp->setAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME, builder.getUnitAttr()); + genContext.ownedVars->push_back(resultSlot); } - else + + builder.create(location, asyncExecOp.getToken()); + + if (resultSlot) { - auto asyncAwaitOp = builder.create(location, asyncExecOp.getToken()); + return V(builder.create(location, resultType, resultSlot)); } return mlir::success(); @@ -872,9 +932,25 @@ namespace mlirgen // in case of detecting value for recursive calls we need to ignore failed calls // last condition we need to reduce posobilities to ignore legitimate failure // TODO: register dummy function declaration at the begginnning of detecting function output - if (result.failed_or_no_value() && genContext.allowPartialResolve && + if (result.failed_or_no_value() && genContext.allowPartialResolve && (callExpr == SyntaxKind::Identifier || callExpr == SyntaxKind::PropertyAccessExpression)) - { + { + // The callee is not resolvable yet, but the arguments still have to be walked - this + // is the same gap section 9.55 closed on `new`, in the other place a discovery pass + // gives up early. Discovery is where a lambda's captures are found, so an expression + // it never visits contributes none: `this.testRay({ start: pos, dir: livec }, scene)` + // inside a closure, with `scene` read nowhere else in it, left `scene` uncaptured and + // the real pass then read the enclosing function's own value from inside the lambda - + // "'ts.Load' op using value defined outside the region". + // + // Errors are ignored for the same reason they are ignored there: the callee failing is + // this branch's own premise, so its arguments can fail with it, and discovery is + // best-effort by construction. + for (auto argument : callExpression->arguments) + { + mlirGen(argument, genContext); + } + // we need to return success to continue code traversing return V(builder.create(location, builder.getNoneType())); } @@ -1017,6 +1093,40 @@ namespace mlirgen builder.create(location, expr); + // Under reference counting `delete` gives up the reference the expression named, so the + // storage that named it has to stop holding one. Otherwise the release that storage + // gets anyway - scope exit for a local, the instance's release routine for a field - + // runs a second time against a block this already let go of, and the second one lands + // wherever the allocator has since put that memory. Whether that faults depends on what + // was allocated in between, which is why `00class_static.ts` needed two `print` calls + // between the delete and the end of the function to show it. + if (compileOptions.isRefCounted()) + { + // `delete new C()` and `delete c`, where `c` is a `const` the compiler kept as a + // value rather than storage: the reference is one nobody has claimed, and §9.30 + // releases those at the end of the block. This is the claim. + if (producesOwnedReference(expr)) + { + consumeOwnedReference(expr); + } + + // And the other way the same reference gets released twice: a variable with real + // storage is deleted through a load of its slot, and the slot is released again on + // the way out. `ts.Delete` then `ts.ReleaseSlot` on the same slot is visible in the + // dialect for `let c = new C(); delete c;`. Storing null is what makes the second + // one a no-operation - every release routine checks its pointer first. This is a + // raw store on purpose: the assignment path would retain and release around it. + MLIRCodeLogic mcl(builder, compileOptions); + auto reference = mcl.GetReferenceFromValue(location, expr); + if (reference && isOwningSlot(location, reference)) + { + auto nullValue = builder.create(location, getNullType()); + mlir::Value clearedValue; + CAST(clearedValue, location, expr.getType(), nullValue, genContext); + builder.create(location, clearedValue, reference); + } + } + return mlir::success(); } @@ -1458,6 +1568,12 @@ namespace mlirgen auto objType = mlir_ts::ObjectType::get(tupleType); auto valueAddr = builder.create(location, mlir_ts::ValueRefType::get(tupleType), builder.getBoolAttr(false)); + + // this block releases what its fields hold when it dies, so it has to take a reference + // to each of them - the same debt an array literal's data block carries, and for the + // same reason there is no assignment on this path to carry it (§9.21) + mlirGenRetainCaptured(location, mlir::ValueRange{tupleValue}); + builder.create(location, tupleValue, valueAddr); auto objValue = builder.create(location, objType, valueAddr); return V(objValue); diff --git a/tslang/lib/TypeScript/MLIRGenFunctions.cpp b/tslang/lib/TypeScript/MLIRGenFunctions.cpp index 33f749b17..28f11cba7 100644 --- a/tslang/lib/TypeScript/MLIRGenFunctions.cpp +++ b/tslang/lib/TypeScript/MLIRGenFunctions.cpp @@ -1309,6 +1309,33 @@ namespace mlirgen return mlir::failure(); } + // A parameter a closure captures is stored in a cell of its own, like a captured local, + // and the cell has to go back the same way - so the frame is listed as an owner of every + // parameter's storage, and scope exit gives back the ones that turned out to be cells. + // + // This list belongs to the function rather than to the body block, which is the scope a + // parameter actually has, and it is wired in only now that the prologue is generated: + // while it was null the prologue's own declarations - a destructured parameter's + // bindings - were not owned by anything, and this is not the slice that changes that. + auto paramCells = std::make_unique>(); + for (auto &prologueOp : entryBlock) + { + if (isa(prologueOp)) + { + paramCells->push_back(prologueOp.getResult(0)); + } + } + + funcGenContext.ownedVars = paramCells.get(); + + // A scope exit walking outwards stops here. The chain came in from whatever context this + // function was generated under - for a nested function, the enclosing function's own + // blocks - and a `return` releasing those would be releasing another frame's locals from + // inside this one. It has always been wrong; it only became reachable now that this + // context has a list of its own, since the walk used to stop at the first context + // without one. + funcGenContext.parentBlockContext = nullptr; + // if we need params only we do not need to process body auto discoverParamsOnly = funcGenContext.allowPartialResolve && funcGenContext.discoverParamsOnly; if (!discoverParamsOnly) @@ -1321,6 +1348,14 @@ namespace mlirgen } } + // Falling off the end of the body reaches here rather than through a `return`, and the + // body block's own exit stops at itself. A `return` inside walks the whole stack outwards + // and so has already released these on its own path. + if (failed(mlirGenReleaseOwned(location, DisposeDepth::CurrentScope, {}, &funcGenContext))) + { + return mlir::failure(); + } + // add exit code if (failed(mlirGenFunctionExit(location, funcGenContext))) { @@ -1452,26 +1487,60 @@ namespace mlirgen return mlir::failure(); } + // How the box will store this variable decides which reference has to be taken, and + // it is not the same question as whether a reference to the variable can be had. + // MLIRCodeLogic::CaptureTypeStorage gives a read-write capture a `ref` field - the + // address of the variable's cell - and everything else a field of the variable's own + // type, which CaptureOpLowering fills by dereferencing. A by-value field is released + // by `releaseCapturedFields` like any other owning field, so the copy needs a + // reference of its own; retaining the cell instead leaves the value with one owner + // fewer than the releases that will run for it, which is what freed the source array + // of a generator out from under the generator (§9.50). + auto capturedByRef = item.second && item.second->getReadWriteAccess(); + // review capturing by ref. it should match storage type auto refValue = mcl.GetReferenceFromValue(location, varValue); - if (refValue) + if (refValue && !capturedByRef) + { + // the box holds a copy, exactly as it does for a value with no reference at all + capturedValues.push_back(refValue); + mlirGenRetainCaptured(location, mlir::ValueRange{varValue}); + } + else if (refValue) { capturedValues.push_back(refValue); // set var as captures if (auto varOp = refValue.getDefiningOp()) { varOp.setCapturedAttr(builder.getBoolAttr(true)); + // the box about to be built is a further owner of this variable's cell + builder.create(location, refValue); } else if (auto paramOp = refValue.getDefiningOp()) { paramOp.setCapturedAttr(builder.getBoolAttr(true)); + builder.create(location, refValue); } else if (auto paramOptOp = refValue.getDefiningOp()) { paramOptOp.setCapturedAttr(builder.getBoolAttr(true)); + builder.create(location, refValue); + } + else if (isCapturedCellSlot(refValue)) + { + // A closure inside a closure, capturing the same variable. There is nothing + // to mark here - the frame that declared the variable already made its + // storage a cell, and this function only knows the cell through its own + // capture box - but the box about to be built owns it exactly as that first + // box does, and releases it when the bound function goes. Without the + // matching retain the inner box gives back a count nobody added. + builder.create(location, refValue); } else { + // no retain here: what makes a variable's storage a cell is being marked + // captured, and nothing was marked. Retaining a stack slot would write a + // count into the frame word in front of it. // TODO: review it. // find out if u need to ensure that data is captured and belong to VariableOp or ParamOp with // captured = true @@ -1482,8 +1551,10 @@ namespace mlirgen } else { - // this is not ref, this is const value + // this is not ref, this is const value - the box holds a copy, and a copy of a + // reference is a further owner of what it points at capturedValues.push_back(varValue); + mlirGenRetainCaptured(location, mlir::ValueRange{varValue}); } } @@ -1530,7 +1601,18 @@ namespace mlirgen auto captureType = mcl.CaptureType(captureVars->getValue()); auto result = mlirGenCreateCapture(location, captureType, capturedValues, genContext); auto captured = V(result); - return builder.create(location, getBoundFunctionType(funcType), captured, funcSymbolOp); + auto boundFuncVal = builder.create(location, getBoundFunctionType(funcType), captured, funcSymbolOp); + + // The capture box was allocated for this closure and nothing else holds it, so the + // closure is its owner - which is the one shape of function value that owns its + // `this`, and the reason for OWNS_CAPTURE_ATTR_NAME. A closure built here and then + // dropped - `apply(v => v.x + base.x, v)` - is released at the end of the block that + // made it (§9.30), which is what gives the box back. + boundFuncVal->setAttr(OWNS_CAPTURE_ATTR_NAME, builder.getUnitAttr()); + builder.create(location, boundFuncVal); + boundFuncVal->setAttr(OWNED_RESULT_ATTR_NAME, builder.getUnitAttr()); + + return V(boundFuncVal); } if (thisValue) diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 9fa64a193..83d40b0cd 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -219,6 +219,7 @@ class MLIRGenImpl #endif mlir::LogicalResult createDeclarationExportGlobalVar(const GenContext &genContext); + mlir::LogicalResult createMemoryModelExportGlobalVar(const GenContext &genContext); mlir::LogicalResult createGenericClassDeclarationExportGlobalVar(const GenContext &genContext); bool isCodeStatment(SyntaxKind kind); @@ -229,11 +230,13 @@ class MLIRGenImpl bool hasGlobalCode(NodeArray statements); + bool hasGlobalInitialization(NodeArray statements); + // appends GlobalConstructorOp after the last one in the module; LAST priority so it runs after CRT init void addGlobalConstructor(mlir::Location location, StringRef funcName); mlir::LogicalResult generateGlobalEntryCode(mlir::Location location, NodeArray statements, - const GenContext &genContext); + bool hasDeferredStatements, const GenContext &genContext); mlir::LogicalResult outputDiagnostics(mlir::SmallVector> &postponedMessages, int notResolved); @@ -430,6 +433,179 @@ class MLIRGenImpl mlir::LogicalResult mlirGen(ts::Block blockAST, const GenContext &genContext, int skipStatements = 0); + // A block whose statements include a top-level `using` needs to dispose even when an + // exception unwinds through it, not only on normal exit. A plain block has no landing pad + // to run that dispose from, so mlirGen(Block) wraps such a block in a synthetic + // catch-less TryOp instead - see mlirGenBlockWithUnwindCleanup. + // + // Checked syntactically, off the AST flag, before generation: by the time the DOM would + // know a `using` was declared, the block's ops are already emitted at the current + // insertion point and there is no clean way to wrap them retroactively. + bool blockDeclaresUsing(ts::Block blockAST, int skipStatements = 0) + { + auto index = 0; + for (auto statement : blockAST->statements) + { + if (index++ < skipStatements) + { + continue; + } + + if ((SyntaxKind)statement != SyntaxKind::VariableStatement) + { + continue; + } + + auto variableStatementAST = statement.as(); + if ((variableStatementAST->declarationList->flags & NodeFlags::Using) == NodeFlags::Using) + { + return true; + } + } + + return false; + } + + // Whether every top-level `using` this block declares initializes directly from + // `new SomeClass(...)`. + // + // Disposing a class instance through the synthesized TryOp is verified working; disposing + // an object literal (`{ [Symbol.dispose]() {...} }`) is not - dropping this check alone + // and compiling `using r = loggy(); throw 1;` fails the build, so the gap is in disposing + // that shape rather than in the wrapping. What actually needs disposing is a semantic fact + // (the initializer's resolved type), not available before generation - see + // blockDeclaresUsing for why the check has to run before that. `new X(...)` is the + // syntactic proxy, and it costs nothing to check. + bool blockUsingInitializersAreAllNewExpr(ts::Block blockAST, int skipStatements = 0) + { + auto index = 0; + for (auto statement : blockAST->statements) + { + if (index++ < skipStatements) + { + continue; + } + + if ((SyntaxKind)statement != SyntaxKind::VariableStatement) + { + continue; + } + + auto variableStatementAST = statement.as(); + if ((variableStatementAST->declarationList->flags & NodeFlags::Using) != NodeFlags::Using) + { + continue; + } + + for (auto &declaration : variableStatementAST->declarationList->declarations) + { + if (!declaration->initializer || (SyntaxKind)declaration->initializer != SyntaxKind::NewExpression) + { + return false; + } + } + } + + return true; + } + + // Whether any `catch` clause nested anywhere inside this block names a type. + // + // On the Itanium path a typed catch cannot be dispatched by the personality routine alone: + // TryOpLowering emits a selector compare and, on the other side of it, a block that + // rethrows because this clause did not match (`cmpValue` there). That rethrow is an exit + // from the function like a `return` is, so the scope's owned locals have to be given back + // before it - and wrapping this block in a cleanup TryOp is what puts them there. Without + // it the ownership verifier reports `00catch_value.ts` exactly, and the reference is + // genuinely leaked when a clause does not match. + // + // Windows generates no such block: the funclet personality performs the type match itself, + // so there is nothing there to leak on and nothing to wrap for. + // + // The walk covers the whole subtree rather than this block's own statements, because the + // locals that would leak are this block's while the `try` that rethrows can be nested any + // depth below it. Nested functions and classes open a scope of their own and are skipped; + // each qualifying block on the way down is wrapped on its own account, and the resulting + // cleanups chain outwards through parentTryOpLandingPad. + // + // A block with nothing to release costs nothing: mlirGenScopeExit writes no operations into + // the cleanup region, TryOpLowering sees an empty one, and the wrapping leaves no trace. + bool blockHasTypedCatch(ts::Block blockAST, int skipStatements = 0) + { + auto found = false; + ts::FilterVisitorSkipFuncsAST visitor(SyntaxKind::CatchClause, [&](CatchClause catchClauseNode) { + if (catchClauseNode->variableDeclaration && catchClauseNode->variableDeclaration->type) + { + found = true; + } + }); + + auto index = 0; + for (auto statement : blockAST->statements) + { + if (index++ < skipStatements) + { + continue; + } + + if (found) + { + break; + } + + visitor.visit(statement); + } + + return found; + } + + mlir::LogicalResult mlirGenBlockWithUnwindCleanup(ts::Block blockAST, const GenContext &genContext, int skipStatements = 0); + + // Whether the insertion point sits inside the catches or finally region of an enclosing + // TryOp. + // + // Synthesizing a TryOp there crashes the compiler: `try { throw 1; } catch (e: int) { + // using r = new Res(); }` segfaults with the wrapping and compiles without it. Nesting a + // synthesized TryOp inside another one's *body* is fine and is exercised by + // 04disposable.ts - it is the catch and finally regions specifically that do not tolerate + // it, which is the half of the old blockIsFunctionRootBody condition that was doing real + // work and was dropped with it. + // + // Re-checked after the ToInvoke fix in Win32ExceptionPass, which retired the sibling + // blockHasNestedUsing guard: this one is still needed, and the crash it avoids still has a + // cause of its own. Note it also costs ownership - localTakesOwnership consults it, so a + // heap local declared in a catch or finally clause is not owned and leaks under -mm=rc. + bool blockIsInsideCatchOrFinally() + { + auto *block = builder.getInsertionBlock(); + while (block) + { + auto *region = block->getParent(); + if (!region) + { + break; + } + + auto *parentOp = region->getParentOp(); + if (!parentOp) + { + break; + } + + if (auto tryOp = dyn_cast(parentOp)) + { + if (region == &tryOp.getCatches() || region == &tryOp.getFinally()) + { + return true; + } + } + + block = parentOp->getBlock(); + } + + return false; + } + mlir::LogicalResult mlirGenNoScopeVarsAndDisposable(ts::Block blockAST, const GenContext &genContext, int skipStatements = 0) { auto location = loc(blockAST); @@ -508,8 +684,463 @@ class MLIRGenImpl return mlir::success(); } + // Everything a scope owes on the way out: dispose what `using` declared, then give up the + // references its locals took. In that order - a disposable is still usable while its + // `[Symbol.dispose]()` runs, and dropping the last reference first could have freed it. + // + // The unwind leg calls this too. It can, because a scope that owes a release has its storage + // hoisted out in front of the `TryOp` (allocateScopeOwnedVarsOutsideOfOperation), so the slot + // dominates the cleanup region as well as the body. + mlir::LogicalResult mlirGenScopeExit(mlir::Location location, DisposeDepth disposeDepth, std::string loopLabel, const GenContext* genContext) + { + EXIT_IF_FAILED(mlirGenDisposable(location, disposeDepth, loopLabel, genContext)); + return mlirGenReleaseOwned(location, disposeDepth, loopLabel, genContext); + } + + // Whether a scope exit that has just finished with this scope still owes the scopes outside + // it. `FullStack` - a `return` - owes all of them. A `break` or `continue` owes every scope + // up to and including the body scope of the loop it targets. + // + // The loop test belongs here, and on `isLoopBodyScope`. Written as + // `disposeDepth == LoopScope && genContext->isLoop && genContext->loopLabel != loopLabel` + // in the "keep going" position it stopped the walk at the first scope that was not itself + // a loop - and since `isLoop` is inherited by every context inside a loop, the very first + // step thought it had already arrived. A `break` or `continue` written inside an `if`, + // which is where they are usually written, then skipped every scope between it and the + // loop, disposing and releasing none of them. Found by the ownership verifier. + static bool scopeExitContinuesOutwards(DisposeDepth disposeDepth, const std::string &loopLabel, + const GenContext *genContext) + { + if (disposeDepth == DisposeDepth::FullStack) + { + return true; + } + + if (disposeDepth != DisposeDepth::LoopScope) + { + return false; + } + + // The label comparison is left exactly as it was, including the empty label the + // recursion below hands the parent. It looks like it should carry the target label + // outwards instead, but the loop sites clear `label` before storing it, so a labelled + // loop's context holds an empty one too - and `continue cont1` then relies on the outer + // loop matching that empty label. 02disposable.ts is the case that proves it. + auto isTargetLoop = genContext->isLoopBodyScope && genContext->loopLabel == loopLabel; + return !isTargetLoop; + } + + // Drops the reference each local of this scope took when it was declared. Shaped after + // mlirGenDisposable, and walks outwards on the same terms, so that a `return` from a + // nested block releases every scope it leaves and a `break` releases up to the loop. + mlir::LogicalResult mlirGenReleaseOwned(mlir::Location location, DisposeDepth disposeDepth, std::string loopLabel, const GenContext* genContext) + { + // the outermost scope of a function has no parent to walk to + if (genContext == nullptr) + { + return mlir::success(); + } + + if (genContext->ownedVars != nullptr) + { + // reverse declaration order, the order a scope is unwound in: a later local may + // hold the only other reference to what an earlier one points at + for (auto storage : llvm::reverse(*genContext->ownedVars)) + { + // Two different debts, and a slot can carry either, both or neither. A cell is + // released as a cell whatever its contents are owned by - the block has to go + // back regardless. A slot that is neither is here only because it might have + // turned into a cell and did not; see trackPossibleCell. + if (isCapturedVariableCell(storage)) + { + builder.create(location, storage); + } + else if (isOwnedLocalSlot(storage)) + { + builder.create(location, storage); + } + } + + // Process-once, as for usingVars: CurrentScopeKeepAfterUse is what the try body + // passes so that the cleanup region, generated after it, still sees the list. + if (disposeDepth == DisposeDepth::CurrentScope) + { + const_cast(genContext)->ownedVars = nullptr; + } + } + + // Outside the test above: a scope that owns nothing itself still stands between a `return` + // and the scopes that do, so having no list of its own is not a reason to stop walking. + // + // DEFENSIVE, and honestly labelled as such - no program has been found that needs it. + // Every block scope is given a list when it is created, so in practice the walk was + // reaching the enclosing scopes anyway; the shapes that looked like they would prove this + // (an owned local plus a `return` out of an `if`, measured both ways) come back identical. + // It is kept because the guard is the wrong shape for what it guards, not because it was + // measured. Section 9.60 records the measurement, and records that what actually leaked + // `raytrace`'s per-light ray was the discarded-temporary placement in + // OwnedReturnConsumptionPass, not this. + if (scopeExitContinuesOutwards(disposeDepth, loopLabel, genContext)) + { + EXIT_IF_FAILED(mlirGenReleaseOwned(location, disposeDepth, {}, genContext->parentBlockContext)); + } + + return mlir::success(); + } + + // Does this reference address a captured variable's cell - a heap block the frame shares + // with the closures that captured it - rather than ordinary frame storage? + // + // The question is asked at scope exit, which is generated after the closure that does the + // capturing, so the answer is settled by then. A `return` written *before* the capture is + // one where the closure cannot exist on that path: releasing the value there is right, and + // all that is lost is the cell, which those paths leak. + // + // A parameter answers yes on the same terms a local does. Being captured is what makes + // storage a cell, and a parameter's storage is the same `ts.Variable` by the time the + // affine pass has run - ParamOpLowering builds one, carrying the marking across. + static bool isCapturedVariableCell(mlir::Value reference) + { + if (auto varOp = reference.getDefiningOp()) + { + return varOp.getCaptured().value_or(false); + } + + if (auto paramOp = reference.getDefiningOp()) + { + return paramOp.getCaptured().value_or(false); + } + + if (auto paramOptionalOp = reference.getDefiningOp()) + { + return paramOptionalOp.getCaptured().value_or(false); + } + + return false; + } + + // Does this reference address a captured variable's cell, reached through a capture box? + // Inside a closure that is how the variable is named: the box's field holds the cell's + // address, so the reference is a load of that field, and neither the load nor the field is + // anything the other cases here recognise. + // + // A cell owns what it holds - releasing the last owner of the cell releases the value in it + // - so assigning through one hands the count over exactly as assigning to the variable in + // its own frame does. Without this, `cur = new Vec(..)` written inside a closure stored a + // value nothing had taken, which §9.30 then released at the end of the block as a discarded + // temporary, freeing the variable the assignment had just set. + // + // The shape is what identifies it: a tuple field whose type is a reference is what a + // capture by reference is, and nothing else builds one. + static bool isCapturedCellSlot(mlir::Value reference) + { + auto loadOp = reference.getDefiningOp(); + if (!loadOp || !isa(loadOp.getType())) + { + return false; + } + + auto propertyRefOp = loadOp.getReference().getDefiningOp(); + if (!propertyRefOp) + { + return false; + } + + auto boxRefType = dyn_cast(propertyRefOp.getObjectRef().getType()); + return boxRefType && isa(boxRefType.getElementType()); + } + + // Does this reference address a local whose scope owns what it holds? Only a variable + // declaration marks its storage that way, so a parameter's slot answers no, and assigning + // through it neither retains nor releases. + bool isOwnedLocalSlot(mlir::Value reference) + { + auto varOp = reference.getDefiningOp(); + return varOp && varOp->hasAttr(OWNED_LOCAL_ATTR_NAME); + } + + // Does this reference address a global that owns what it holds? It does, and this is the + // one slot with no matching release: a global is a root, it outlives every scope, and the + // value in it at exit is never given back. That is why takeOwnershipOfLocal excludes + // globals - there is no scope to release from - and it is also why the retain was missing + // entirely, which is the whole of the bug: `g = new C()` stored the instance and then gave + // its reference back at the end of the function that built it (§9.30), leaving the global + // pointing at freed memory. `nbody.ts` is that program - `init()` builds the system, and + // the first method call that reads a field out of it writes a refcount into a freed block. + // + // Overwriting one still hands the count over, so the release on the outgoing value runs as + // for any other owning slot. That is safe from the first assignment onwards because a + // global with no initializer is zero, not undef (`ts.Default` lowers to `LLVM::ZeroOp`), + // and null is what every release routine treats as nothing to do. + bool isOwnedGlobalSlot(mlir::Location location, mlir::Value reference) + { + if (!reference.getDefiningOp()) + { + return false; + } + + auto refType = dyn_cast(reference.getType()); + return refType && mth.ownsHeapMemory(location, refType.getElementType()); + } + + // Does this reference address a field of an instance that will release what the field + // holds? A class or object instance does: it is a heap block with a release routine, and + // that routine releases what each of its fields owns (`releaseFields` in + // OwnershipRoutineLogic). So overwriting such a field carries the same debt as overwriting + // an owned local - the incoming value gains an owner, the outgoing one loses one. + // + // A record held *inline* - a tuple in a local - answers the same question, but conditionally: + // its fields are released by whatever holds the record, so the field owns exactly when the + // storage under it does. That is the recursive case below, and it is why this is not simply + // "the base is a heap reference". + // + // This was first excluded outright, on the reasoning that retaining into a record nothing + // releases would leak. The half of that reasoning which was wrong is that an owned local + // holding a record *does* release its fields: `ts.RetainSlot` and `ts.ReleaseSlot` on a + // record-shaped slot go through the type's own routines, which walk its 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 without taking or giving anything - and + // two such assignments of one value released it twice and freed it live (§9.23). + bool isOwnedFieldSlot(mlir::Location location, mlir::Value reference) + { + auto propertyRefOp = reference.getDefiningOp(); + if (!propertyRefOp) + { + return false; + } + + auto objectRef = propertyRefOp.getObjectRef(); + if (isa(objectRef.getType())) + { + // an inline record: it owns its fields only if something owns the record. A + // parameter's slot, and the scratch storage a literal is built in, both answer no - + // nothing releases those, so retaining into them would leak. + if (!isOwningSlot(location, objectRef)) + { + return false; + } + } + else if (!isa(objectRef.getType())) + { + return false; + } + + // an `int` field has nothing to hand over; only ask the type helper once past the + // structural checks, since it walks the type + auto refType = dyn_cast(reference.getType()); + return refType && mth.ownsHeapMemory(location, refType.getElementType()); + } + + // Does this reference address an element of an array that will release what the element + // holds? A `T[]` value is { data, length }, and its release routine walks the elements of + // the data block before freeing it (`buildArrayBody` in OwnershipRoutineLogic) - the exact + // mirror of what `releaseFields` does for an instance. So `arr[i] = x` carries the same + // debt as `obj.f = x`. + // + // Only ArrayType. `ts.ElementRef` also addresses a ConstArrayType, whose data is a static + // literal nothing releases, and a StringType, whose characters are not references at all. + // + // This covers the element *store*. The array-mutating builtins - push, unshift, 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; neither is here. The taking-out half + // asks the same question a return does (give up a reference to a value the caller is about + // to hold), so the two belong in one slice, not this one. + bool isOwnedElementSlot(mlir::Location location, mlir::Value reference) + { + auto elementRefOp = reference.getDefiningOp(); + if (!elementRefOp) + { + return false; + } + + if (!isa(elementRefOp.getArray().getType())) + { + return false; + } + + auto refType = dyn_cast(reference.getType()); + return refType && mth.ownsHeapMemory(location, refType.getElementType()); + } + + // Does this value already carry a reference for whoever receives it, rather than one the + // receiver has to take for itself? Only an operation explicitly marked as such answers yes - + // see OWNED_RESULT_ATTR_NAME. Nothing is inferred from an operation merely being a call: a + // runtime helper, or a function from a module built before returns retained their result, + // hands back a heap value with no retain behind it, and consuming one of those would skip a + // retain nobody performed. Answering "no" for something that was in fact owned only leaks. + bool producesOwnedReference(mlir::Value value) + { + if (!value) + { + return false; + } + + auto *definingOp = value.getDefiningOp(); + return definingOp && definingOp->hasAttr(OWNED_RESULT_ATTR_NAME); + } + + // Records that a receiver has taken over the reference this value carried, so nothing later + // reads it as a temporary nobody claimed. Every site that answers `producesOwnedReference` + // by skipping its retain calls this; what is left unmarked is what §9.30 releases. + void consumeOwnedReference(mlir::Value value) + { + if (auto *definingOp = value ? value.getDefiningOp() : nullptr) + { + definingOp->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, builder.getUnitAttr()); + } + } + + // Takes a reference to each of `values` that owns heap memory. + // + // For construction sites that fill an owning block in one go rather than through an + // assignment - an array literal's data block, a boxed object literal's storage. The block's + // release routine walks what it holds when it dies, so it has to have taken a reference to + // each of them; nothing else on this path does. + // + // Without this the block gives up references it never took, and that is not a leak but an + // over-release, reachable today: an element seeded by a literal is one below an equivalent + // field, one overwrite is masked by the unconsumed birth reference, and a second overwrite + // of the same value takes it past zero and frees it while a local still holds it. See + // §9.21 in docs/reference-counting-evaluation.md. + // + // A record-shaped value retains through its own routine, which walks its owning fields, so + // the boxed-literal case needs one of these on the whole tuple rather than one per field. + void mlirGenRetainCaptured(mlir::Location location, mlir::ValueRange values) + { + for (auto value : values) + { + if (!mth.ownsHeapMemory(location, value.getType())) + { + continue; + } + + // a value that already carries a reference for its receiver is taken over rather + // than retained again (§9.25) - `[new C()]`, and `return new C()` alike + if (producesOwnedReference(value)) + { + consumeOwnedReference(value); + } + else + { + builder.create(location, value); + } + } + } + + // Does building a `string` out of a value of this type allocate a new one? + // + // Only the printing conversions do: `ConvertLogic`'s itoa and f64ToString, and + // `ts.CharToString`, each allocate a buffer and write into it. Everything else that reaches + // the plain cast - a boolean, `undefined`, a string literal - hands back a global, which is + // immortal and owns nothing. A literal is asked about by its element type, since that is + // what the lowering unwraps it to before choosing. + static bool castToStringAllocates(mlir::Type valueType) + { + if (auto literalType = dyn_cast(valueType)) + { + valueType = literalType.getElementType(); + } + + return isa(valueType) || valueType.isIntOrIndex(); + } + + // Gives a freshly built string the same standing as every other producer of a new heap + // value: the retain makes the reference real, and the mark says a receiver may take it over + // rather than adding one of its own - which is also what lets §9.30 give it back where + // nothing receives it at all. + // + // Both halves are needed together, and the retain is what makes this safe to be generous + // with: a value wrongly counted as fresh gains a reference and a release for it, which is + // balanced, where a mark on its own would hand a receiver a reference nobody took. + void markFreshStringOwned(mlir::Location location, mlir::Value value) + { + if (!value || !isa(value.getType())) + { + return; + } + + auto *definingOp = value.getDefiningOp(); + if (!definingOp || definingOp->hasAttr(OWNED_RESULT_ATTR_NAME)) + { + return; + } + + builder.create(location, value); + definingOp->setAttr(OWNED_RESULT_ATTR_NAME, builder.getUnitAttr()); + } + + // Storage that hands ownership over when it is overwritten: the incoming value gains an + // owner and the outgoing one loses one. + // + // A cell is here for the same reason its box-side view isCapturedCellSlot is: the cell owns + // what it holds, so a store into one has to hand the count over. This is the view from the + // declaring frame - `p = ..` where the closure below captured `p` - and the two must agree, + // since they address the very same block. + bool isOwningSlot(mlir::Location location, mlir::Value reference) + { + return isOwnedLocalSlot(reference) || isCapturedVariableCell(reference) || + isCapturedCellSlot(reference) || isOwnedGlobalSlot(location, reference) || + isOwnedFieldSlot(location, reference) || isOwnedElementSlot(location, reference); + } + + // One `using` declaration's `[Symbol.dispose]()`, at one scope exit. + // + // A declaration whose storage was hoisted out in front of a TryOp is disposed from two + // places that cannot see each other's progress: the scope exit written in the try body, and + // the cleanup region reached by the unwind edge. Neither knows how far the other got, and + // both were wrong about it in opposite directions - the two defects section 9.62 records + // and leaves open: + // + // - the body's own disposal is a call in the try body, so it unwinds to that same + // cleanup. A `[Symbol.dispose]()` that throws was therefore followed by the cleanup + // disposing the very same variable a second time - and the second throw, arriving + // while the first was still unwinding, terminates the process. + // - the `using` initializer's `new` is in the try body too. A constructor that throws + // left the cleanup disposing a slot nothing had ever been stored into, reading a + // vtable out of whatever the frame happened to hold. That one is an access violation, + // not a subtlety. + // + // The guard is what the cleanup was missing: a boolean beside the slot, false until the + // initializing store has run, and cleared before each disposal rather than after it. Read + // and cleared in that order, the second visitor to a variable always finds it false - + // whether it arrives because the first one threw, or because the first one never ran. + // + // A `using` in a plain block has no guard and takes the direct path: with no cleanup + // region there is no second visitor, and nothing to be wrong about. + mlir::LogicalResult mlirGenDisposeOne(mlir::Location location, ts::VariableDeclarationDOM::TypePtr varDecl, + mlir::Value varValue, const GenContext &genContext) + { + auto disposeGuard = varDecl->getDisposeGuard(); + if (!disposeGuard) + { + auto callResult = mlirGenCallThisMethod(location, varValue, SYMBOL_DISPOSE, undefined, {}, genContext); + EXIT_IF_FAILED(callResult); + return mlir::success(); + } + + auto isLive = builder.create(location, getBooleanType(), disposeGuard); + auto ifOp = builder.create(location, isLive, /*withElseRegion=*/false); + + mlir::OpBuilder::InsertionGuard insertGuard(builder); + builder.setInsertionPointToStart(&ifOp.getThenRegion().front()); + + auto notLive = builder.create(location, getBooleanType(), builder.getBoolAttr(false)); + builder.create(location, notLive, disposeGuard); + + auto callResult = mlirGenCallThisMethod(location, varValue, SYMBOL_DISPOSE, undefined, {}, genContext); + EXIT_IF_FAILED(callResult); + + return mlir::success(); + } + mlir::LogicalResult mlirGenDisposable(mlir::Location location, DisposeDepth disposeDepth, std::string loopLabel, const GenContext* genContext) { + // as in mlirGenReleaseOwned: the walk outwards ends at the function + if (genContext == nullptr) + { + return mlir::success(); + } + if (genContext->usingVars != nullptr) { for (auto vi : *genContext->usingVars) @@ -520,8 +1151,7 @@ class MLIRGenImpl llvm_unreachable("can't find local variable"); } - auto callResult = mlirGenCallThisMethod(location, varInTable.first, SYMBOL_DISPOSE, undefined, {}, *genContext); - EXIT_IF_FAILED(callResult); + EXIT_IF_FAILED(mlirGenDisposeOne(location, vi, varInTable.first, *genContext)); } // remove when used @@ -530,13 +1160,15 @@ class MLIRGenImpl // NOTE: upward mailbox into caller context (process-once) - see docs/MLIRGen-refactoring-review.md A7 const_cast(genContext)->usingVars = nullptr; } + } - auto continueIntoDepth = disposeDepth == DisposeDepth::FullStack - || disposeDepth == DisposeDepth::LoopScope && genContext->isLoop && genContext->loopLabel != loopLabel; - if (continueIntoDepth) - { - EXIT_IF_FAILED(mlirGenDisposable(location, disposeDepth, {}, genContext->parentBlockContext)); - } + // Outside the test above: a scope that declared no `using` of its own is not the end of + // the walk. Same shape, and the same honest caveat, as mlirGenReleaseOwned below - every + // block scope is given a list when it is created, so this is defensive rather than + // something a program was found to need. + if (scopeExitContinuesOutwards(disposeDepth, loopLabel, genContext)) + { + EXIT_IF_FAILED(mlirGenDisposable(location, disposeDepth, {}, genContext->parentBlockContext)); } return mlir::success(); @@ -695,7 +1327,7 @@ class MLIRGenImpl VariableDeclarationInfo(CompileOptions& compileOptions) : compileOptions(compileOptions), variableName(), fullName(), initial(), type(), storage(), globalOp(), varClass(), scope{VariableScope::Local}, isFullName{false}, isGlobal{false}, isConst{false}, isExternal{false}, isExport{false}, isImport{false}, isSpecialization{false}, allocateOutsideOfOperation{false}, allocateInContextThis{false}, comdat{Select::NotSet}, deleted{false}, isUsed{false}, - needsIdentityStorage{false}, typeAndInitResolved{false} + needsIdentityStorage{false}, typeAndInitResolved{false}, disposeGuard() { }; @@ -764,7 +1396,7 @@ class MLIRGenImpl } allocateOutsideOfOperation = genContext.allocateVarsOutsideOfOperation - || genContext.allocateUsingVarsOutsideOfOperation && varClass_.isUsing; + || genContext.allocateScopeOwnedVarsOutsideOfOperation && varClass_.isUsing; allocateInContextThis = genContext.allocateVarsInContextThis; isGlobal = scope == VariableScope::Global || varClass == VariableType::Var; @@ -959,8 +1591,30 @@ class MLIRGenImpl bool isUsed; bool needsIdentityStorage; bool typeAndInitResolved; + + // See createLocalVariable: set only for a `using` declaration hoisted out in front of a + // TryOp, and carried onto the VariableDeclarationDOM that mlirGenDisposable walks. + mlir::Value disposeGuard; }; + // Will this declaration make its scope the owner of what it holds - a retain now, a release + // at every exit? Asked twice from two different places, and they must not disagree: the + // hoisting decision below reads it before the storage exists, and takeOwnershipOfLocal reads + // it again once the storage does. A local that is hoisted but not owned only wastes a move; + // one that is owned but not hoisted puts a release in a region its slot does not dominate, + // and the module stops verifying. + // + // Everything excluded here is excluded because the frame borrows the reference rather than + // owning it; takeOwnershipOfLocal documents each case. + bool localTakesOwnership(mlir::Location location, struct VariableDeclarationInfo &variableDeclarationInfo, + const GenContext &genContext) + { + return genContext.ownedVars != nullptr && !variableDeclarationInfo.isGlobal && + !variableDeclarationInfo.deleted && !variableDeclarationInfo.allocateInContextThis && + variableDeclarationInfo.initial && variableDeclarationInfo.type && + mth.ownsHeapMemory(location, variableDeclarationInfo.type) && !blockIsInsideCatchOrFinally(); + } + mlir::LogicalResult adjustLocalVariableType(mlir::Location location, struct VariableDeclarationInfo &variableDeclarationInfo, const GenContext &genContext) { auto type = variableDeclarationInfo.type; @@ -1041,6 +1695,16 @@ class MLIRGenImpl return mlir::failure(); } + // An owned local is hoisted for the same reason a `using` one is: its release belongs on + // the unwind leg too, and the cleanup region does not see storage declared in the body. + // The decision cannot be made in detectFlags with the rest - it needs the type, and the + // type is only known here. + if (genContext.allocateScopeOwnedVarsOutsideOfOperation && !variableDeclarationInfo.allocateOutsideOfOperation + && localTakesOwnership(location, variableDeclarationInfo, genContext)) + { + variableDeclarationInfo.allocateOutsideOfOperation = true; + } + // scope to restore inserting point { mlir::OpBuilder::InsertionGuard insertGuard(builder); @@ -1065,6 +1729,19 @@ class MLIRGenImpl variableDeclarationInfo.setStorage(varOpValue); } + + // The dispose guard goes beside the storage, and for the same reason: it is read + // from the cleanup region, so it has to be declared where that region can see it. + // Its initial value is what makes it useful - the slot is not disposable until the + // initializer's own store has run, and that store is back in the try body. + if (variableDeclarationInfo.allocateOutsideOfOperation && variableDeclarationInfo.varClass.isUsing) + { + auto notYetLive = + builder.create(location, getBooleanType(), builder.getBoolAttr(false)); + variableDeclarationInfo.disposeGuard = builder.create( + location, mlir_ts::RefType::get(getBooleanType()), notYetLive, + builder.getBoolAttr(false), builder.getIndexAttr(0)); + } } // init must be in its normal place @@ -1072,6 +1749,26 @@ class MLIRGenImpl && variableDeclarationInfo.initial && variableDeclarationInfo.storage) { + // A generator's locals cannot live in its frame - the state machine has to resume - + // so each becomes a field of a heap state object, and that object's release routine + // gives back every field that owns memory. localTakesOwnership excludes these for + // exactly that reason, the frame is not their owner, and so nothing ever *took* the + // reference the object will later give back. The store is where it has to happen: + // this is the object's field gaining a value, which is the same debt `obj.f = x` + // carries. See docs/reference-counting-evaluation.md section 9.50. + if (variableDeclarationInfo.allocateInContextThis && compileOptions.isRefCounted() && + mth.ownsHeapMemory(location, variableDeclarationInfo.type)) + { + if (producesOwnedReference(variableDeclarationInfo.initial)) + { + consumeOwnedReference(variableDeclarationInfo.initial); + } + else + { + builder.create(location, variableDeclarationInfo.initial); + } + } + auto storeOp = builder.create(location, variableDeclarationInfo.initial, variableDeclarationInfo.storage); if (variableDeclarationInfo.varClass.atomic) { @@ -1094,6 +1791,17 @@ class MLIRGenImpl // { // storeOp->setAttr(INVARIANT_ATTR_NAME, builder.getBoolAttr(true)); // } + + // Armed after the store, never before it: everything between the guard's own + // declaration and this point is the initializer, and an exception thrown there - + // out of the constructor, most of all - has to reach a cleanup that disposes + // nothing. + if (variableDeclarationInfo.disposeGuard) + { + auto live = + builder.create(location, getBooleanType(), builder.getBoolAttr(true)); + builder.create(location, live, variableDeclarationInfo.disposeGuard); + } } return mlir::success(); @@ -1333,6 +2041,10 @@ class MLIRGenImpl mlir::LogicalResult registerVariableDeclaration(mlir::Location location, VariableDeclarationDOM::TypePtr variableDeclaration, struct VariableDeclarationInfo &variableDeclarationInfo, bool showWarnings, const GenContext &genContext); + void takeOwnershipOfLocal(mlir::Location location, struct VariableDeclarationInfo &variableDeclarationInfo, const GenContext &genContext); + + void trackPossibleCell(struct VariableDeclarationInfo &variableDeclarationInfo, const GenContext &genContext); + mlir::Type registerVariable(mlir::Location location, StringRef name, bool isFullName, VariableClass varClass, TypeValueInitFuncType func, const GenContext &genContext, bool showWarnings = false, bool forceLocalVar = false); @@ -2266,6 +2978,27 @@ class MLIRGenImpl return mlir::Type(); } + // Casts `expressionValue` in place to the function's declared return type, if it has one + // and the value is not already of it. Nothing to do when the return type is being inferred: + // there is no declared type to convert to, and mlirGenReturnValue's own cast below is then + // a no-op as well. + // + // Split out so that the retain a return performs can be placed after the conversion - see + // its call sites. Calling it twice is harmless: the second finds the types already equal. + mlir::LogicalResult castToDeclaredReturnType(mlir::Location location, mlir::Value &expressionValue, + const GenContext &genContext) + { + auto returnType = getExplicitReturnTypeOfCurrentFunction(genContext); + if (!returnType || !expressionValue || returnType == expressionValue.getType()) + { + return mlir::success(); + } + + CAST_A(castValue, location, returnType, expressionValue, genContext); + expressionValue = castValue; + return mlir::success(); + } + mlir::LogicalResult mlirGenReturnValue(mlir::Location location, mlir::Value expressionValue, bool yieldReturn, const GenContext &genContext) { @@ -3209,6 +3942,16 @@ class MLIRGenImpl // condition auto isDefaultCase = SyntaxKind::DefaultClause == (SyntaxKind)caseBlock; auto isDefaultAsFirstCase = index == 0 && clauses.size() > 1; + + // The narrowing a `case` introduces is emitted into the case BODY, below - never here. + // A condition only ever reads the discriminant, and that is already loaded once before + // the first case and shared by all of them. Narrowing here instead would reinterpret the + // union payload as this case's member before knowing the discriminant matches it, and a + // member holding references is then retained through whatever the payload happens to + // hold: where the union carries a smaller member, everything above it is uninitialized, + // and under `-mm=rc` those bytes get walked as pointers. + Expression caseExpr; + mlir::Value caseValue; if (SyntaxKind::CaseClause == (SyntaxKind)caseBlock) { mlir::OpBuilder::InsertionGuard guard(builder); @@ -3218,12 +3961,10 @@ class MLIRGenImpl setPreviousCondOrJumpOp(previousConditionOrFirstBranchOp, caseConditionBlock); } - auto caseExpr = caseBlock.as()->expression; + caseExpr = caseBlock.as()->expression; auto result = mlirGen(caseExpr, genContext); EXIT_IF_FAILED_OR_NO_VALUE(result) - auto caseValue = V(result); - - extraCode(caseExpr, caseValue); + caseValue = V(result); auto switchValueEffective = switchValue; auto actualCaseType = mth.stripLiteralType(caseValue.getType()); @@ -3287,6 +4028,14 @@ class MLIRGenImpl pendingConditions.clear(); + // the narrowed binding, now that this block is only reached when the case matched. + // It has to precede both the generated statements it may add and the body's own, + // which are what resolve the name it registers. + if (caseValue) + { + extraCode(caseExpr, caseValue); + } + // process body case if (genContext.generatedStatements.size() > 0) { @@ -4174,6 +4923,30 @@ class MLIRGenImpl return mlir::failure(); } + // Overwriting owning storage hands the count over: the incoming value gains an + // owner and the outgoing one loses it. Retaining first is what makes `x = x` safe - + // releasing first could drop the last reference and free the value about to be + // stored back. Without this the release that eventually runs for this storage - + // scope exit for a local, the instance's release routine for a field - would give + // up a reference the assignment never took. + if (isOwningSlot(location, loadOp.getReference())) + { + // `h.item = new C()` arrives already owned (§9.25), so the slot takes that + // reference over instead of adding one. The release still runs either way - + // what the slot was holding has to be given up regardless of where the + // incoming reference came from. + if (producesOwnedReference(savingValue)) + { + consumeOwnedReference(savingValue); + } + else + { + builder.create(location, savingValue); + } + + builder.create(location, loadOp.getReference()); + } + // TODO: when saving const array into variable we need to allocate space and copy array as we need to have // writable array auto storeOp = builder.create(location, savingValue, loadOp.getReference()); @@ -4673,6 +5446,36 @@ class MLIRGenImpl return mlir::success(); } + // The order the arithmetic operators promote in, widest first: the first type either + // operand already has is the one both are cast to. Shared by `+` and by the general + // arithmetic/comparison path below, which is the point -- they disagreed, and `+` was + // the one that was wrong (§9.67). + SmallVector numericPromotionOrder() + { + return { + builder.getF128Type(), + getNumberType(), builder.getF64Type(), builder.getI64Type(), SInt(64), builder.getIndexType(), + builder.getF32Type(), SInt(32), builder.getI32Type(), + builder.getF16Type(), SInt(16), builder.getI16Type(), + SInt(8), builder.getI8Type() + }; + } + + // "Numeric enough that promoting it is unambiguous." `!ts.number` is a dialect type + // rather than a builtin float, so `isIntOrIndexOrFloat()` alone does not see it -- the + // same pairing appears wherever this question is asked (see getIndexType usage below). + // `boolean` is deliberately excluded: it is numeric under arithmetic, but it needs + // widening to `number` first rather than promotion against the other operand. + bool isPromotableNumeric(mlir::Type type) + { + if (isa(type)) + { + return false; + } + + return isa(type) || type.isIntOrIndexOrFloat(); + } + bool syncTypes(mlir::Location location, mlir::Type type, mlir::Value &leftExpressionValue, mlir::Value &rightExpressionValue, const GenContext &genContext) { auto hasType = leftExpressionValue.getType() == type || @@ -4823,6 +5626,55 @@ class MLIRGenImpl break; case SyntaxKind::PlusToken: { + // `+` is the one arithmetic operator that is also string concatenation, so it + // cannot simply promote its operands the way `-`, `*` and `/` promote theirs: + // `x + 1` where x is a string has to stay concat. That is why the code below + // syncs the right operand TO the left one rather than promoting both. + // + // When both operands are unambiguously numeric there is no concat to protect, + // and syncing right-to-left is then just wrong: it truncated the wider side + // before the addition rather than after it, so `2 + 3.5` read 5 and + // `1 + (-0.5)` read 1 (§9.67). Promote those exactly like every other + // arithmetic operator, and leave every other shape -- `any`, unions, objects + // with `[Symbol.toPrimitive]`, `undefined`, `null` -- on the path it was on. + { + auto leftIsString = isa(leftExpressionValue.getType()); + auto rightIsString = isa(rightExpressionValue.getType()); + + auto promotable = [&](mlir::Value value) { + return isPromotableNumeric(value.getType()) || isa(value.getType()); + }; + + if (!leftIsString && !rightIsString && promotable(leftExpressionValue) && promotable(rightExpressionValue)) + { + // Booleans widen to `number` before anything else, so that `true + true` + // is 2 rather than wrapping in i1, and so that `true + 2.5` promotes + // against a number instead of dragging 2.5 down to a boolean. + if (isa(leftExpressionValue.getType())) + { + CAST(leftExpressionValue, location, getNumberType(), leftExpressionValue, genContext); + } + + if (isa(rightExpressionValue.getType())) + { + CAST(rightExpressionValue, location, getNumberType(), rightExpressionValue, genContext); + } + + if (leftExpressionValue.getType() != rightExpressionValue.getType()) + { + for (auto type : numericPromotionOrder()) + { + if (syncTypes(location, type, leftExpressionValue, rightExpressionValue, genContext)) + { + break; + } + } + } + + break; + } + } + // this is exactly the untyped default: case below (left/right type sync, // string-preferring) -- PlusToken used to fall through to it unconditionally. // Preserved as-is so string concat (`"fo" + 1`) and ordinary numeric-literal @@ -4896,13 +5748,7 @@ class MLIRGenImpl if (leftExpressionValue.getType() != rightExpressionValue.getType()) { // TODO: do we need to sync type for all Ops? - static SmallVector types = { - builder.getF128Type(), - getNumberType(), builder.getF64Type(), builder.getI64Type(), SInt(64), builder.getIndexType(), - builder.getF32Type(), SInt(32), builder.getI32Type(), - builder.getF16Type(), SInt(16), builder.getI16Type(), - SInt(8), builder.getI8Type() - }; + auto types = numericPromotionOrder(); auto r = syncUnionTypes(location, leftExpressionValue, rightExpressionValue, genContext); if (r.value) @@ -4979,6 +5825,16 @@ class MLIRGenImpl result = builder.create(location, leftExpressionValue.getType(), builder.getI32IntegerAttr((int)opCode), leftExpressionValue, rightExpressionValue); + + // `+` on strings is the one arithmetic operator that allocates: it becomes + // `ts.StringConcat`, which builds a new string. A receiver takes that reference + // over; `("a" + b).length`, where there is no receiver, gives it back at the end of + // the block instead of leaking (§9.37). + if (opCode == SyntaxKind::PlusToken) + { + markFreshStringOwned(location, result); + } + break; } @@ -7418,6 +8274,9 @@ class MLIRGenImpl arrayValues.push_back(arrayValue); } + // the data block about to be filled releases every element when it dies + mlirGenRetainCaptured(location, arrayValues); + auto newArrayOp = builder.create(location, getArrayType(arrayInfo.arrayElementType), arrayValues); return V(newArrayOp); @@ -8082,6 +8941,19 @@ class MLIRGenImpl MLIRCodeLogic mcl(builder, compileOptions); auto capturedValue = mlirGenCreateCapture(location, mcl.CaptureType(accumulatedCaptureVars), accumulatedCapturedValues, genContext); + + // The object is the box's only owner - nothing else holds it, and the box is born + // unowned like every other block - so it takes the one reference here and gives it + // back in its release routine, where `releaseFields` treats a `ref>` field + // as the capture box it is. A closure's box is owned the same way, through the tag on + // the bound function (§9.33); this is the object-shaped half of that, and the half a + // generator's state object needs, since a generator is an object literal the compiler + // wrote. See docs/reference-counting-evaluation.md section 9.52. + if (compileOptions.isRefCounted() && capturedValue) + { + builder.create(location, capturedValue); + } + if (mlir::failed(addObjectFieldInfo(location, oli, MLIRHelper::TupleFieldName(CAPTURED_NAME, builder.getContext()), capturedValue, mlir::Type(), genContext))) { return mlir::failure(); } @@ -8962,7 +9834,15 @@ class MLIRGenImpl auto retVarInfo = symbolTable.lookup(RETURN_VARIABLE_NAME); if (retVarInfo.second) { - builder.create(location, castToRet, retVarInfo.first); + // This body is built op by op rather than from a `return` statement, so + // the retain a return performs has to be repeated here - see the + // ReturnStatement path in MLIRGenStatements. Without it the instance is + // released on the way out and the caller of `new I(...)` through a + // constructor interface is handed a block that has already been given + // back. + auto returnValue = V(castToRet); + mlirGenRetainCaptured(location, mlir::ValueRange{returnValue}); + builder.create(location, returnValue, retVarInfo.first); } else { diff --git a/tslang/lib/TypeScript/MLIRGenModule.cpp b/tslang/lib/TypeScript/MLIRGenModule.cpp index 8bcbac521..142398370 100644 --- a/tslang/lib/TypeScript/MLIRGenModule.cpp +++ b/tslang/lib/TypeScript/MLIRGenModule.cpp @@ -245,7 +245,7 @@ namespace mlirgen MLIRDebugInfoHelper mdi(builder, debugScope); mdi.setFile(mainSourceFileName); - location = mdi.getCompileUnit(location, "TypeScript Native Compiler", isOptimized); + location = mdi.getCompileUnit(location, "TypeScript Compiler", isOptimized); } // We create an empty MLIR module and codegen functions one at a time and @@ -339,6 +339,46 @@ namespace mlirgen #endif } + // Records which memory model this module was built under, so an importer can tell whether + // objects arriving from it are managed the same way its own are. Emitted alongside the + // declaration text and under the same condition: a module that exports no declarations + // cannot be imported, so there is no boundary to mark. + mlir::LogicalResult MLIRGenImpl::createMemoryModelExportGlobalVar(const GenContext &genContext) + { + if (!declExports.rdbuf()->in_avail() || !compileOptions.embedExportDeclarations) + { + return mlir::success(); + } + + auto modelName = std::string(memoryModelName(compileOptions.memoryModel)); + + auto typeWithInit = [&](mlir::Location location, const GenContext &genContext) { + auto litValue = V(mlirGenStringValue(location, modelName, true)); + return std::make_tuple(litValue.getType(), litValue, TypeProvided::No); + }; + + auto loc = mlir::UnknownLoc::get(builder.getContext()); + + VariableClass varClass = VariableType::Var; + varClass.isExport = true; + varClass.isPublic = true; + + // the model is part of the symbol name, so reading it back is a symbol enumeration + // rather than a data load + std::string varName(SHARED_LIB_MEMORY_MODEL); + varName.append(modelName); + varName.append("_"); + varName.append(llvm::sys::path::stem(llvm::sys::path::filename(mainSourceFileName))); + varName.append("_"); + varName.append(to_string(hash_value(mainSourceFileName))); + + auto varNameRef = StringRef(varName).copy(stringAllocator); + + registerVariable(loc, varNameRef, true, varClass, typeWithInit, genContext); + + return mlir::success(); + } + mlir::LogicalResult MLIRGenImpl::createGenericClassDeclarationExportGlobalVar(const GenContext &genContext) { if (!genericDeclExports.rdbuf()->in_avail() || !compileOptions.embedExportDeclarations) @@ -481,6 +521,23 @@ namespace mlirgen return anyCode; } + // Whether anything at the root runs when the program starts - code, or a variable whose + // initializer does. Deliberately a wider question than hasGlobalCode: that one asks only + // whether an entry function has to be built to hold statements held back from the module + // level, and answering it "yes" for variables moves them out of the module scope where the + // file's own functions have to be able to see them. + bool MLIRGenImpl::hasGlobalInitialization(NodeArray statements) { + for (auto &statement : statements) + { + if (isCodeStatment(statement) || statement == SyntaxKind::VariableStatement) + { + return true; + } + } + + return false; + } + void MLIRGenImpl::addGlobalConstructor(mlir::Location location, StringRef funcName) { mlir::OpBuilder::InsertionGuard insertGuard(builder); @@ -495,7 +552,7 @@ namespace mlirgen } mlir::LogicalResult MLIRGenImpl::generateGlobalEntryCode(mlir::Location location, NodeArray statements, - const GenContext &genContext) + bool hasDeferredStatements, const GenContext &genContext) { // create function //auto name = MLIRHelper::getAnonymousName(location, ".main", ""); @@ -505,6 +562,13 @@ namespace mlirgen if (theModule.lookupSymbol(fullGlobalFuncName)) { + // a user-written `main` already is the entry point, so with nothing deferred to run + // ahead of it there is nothing left to generate + if (!hasDeferredStatements) + { + return mlir::success(); + } + // create global ctor name = MLIRHelper::getAnonymousName(location, "." MAIN_ENTRY_NAME, ""); fullGlobalFuncName = getFullNamespaceName(name); @@ -518,6 +582,13 @@ namespace mlirgen if (mlir::failed(mlirGenFunctionBody(location, name, fullGlobalFuncName, funcType, [&](mlir::Location location, const GenContext &genContext) { + // nothing was held back from the module level, so this is an empty entry point + // that exists only to be the program's entry (see the call site) + if (!hasDeferredStatements) + { + return mlir::success(); + } + for (auto &statement : statements) { auto isVariableStatement = statement == SyntaxKind::VariableStatement; @@ -676,9 +747,27 @@ namespace mlirgen if (isMain && notResolved == 0) { - // generate code to run at global entry - if (anyGlobalCode && mlir::failed( - generateGlobalEntryCode(loc(module), module->statements, genContext))) + // generate code to run at global entry. + // + // A program still needs `main` when the root holds no code to defer into it: root-level + // variables initialize from the global constructors either way, but with no `main` there + // is nothing for the JIT to call and nothing for the CRT to link against, which is how + // `class S {} const s = new S();` used to fail with "Symbols not found: [ main ]". + // + // A root that only declares things gets no entry point, because that is what a library + // looks like and its object is linked next to a program that has a `main` of its own - + // emitting one here is a duplicate symbol at link time. + // + // `isExecutable` alone is not the test: it is set only by `--emit=exe`, while everything + // that links a program compiles with `--emit=obj` and drives the linker itself (same trap + // as giveEntryPointAnExitCode in LowerToLLVM.cpp). But `--emit=obj` compiles the libraries + // too, and a library root initializing a variable looks exactly like a program root doing + // the same, so the object path has to be told which file is the program - that is what + // generateEntryPoint carries. Guessing it from the emit action instead put a `main` in + // every library object, and two of those failed to link. + auto needsEntryPoint = compileOptions.generateEntryPoint && hasGlobalInitialization(module->statements); + if ((anyGlobalCode || needsEntryPoint) && mlir::failed( + generateGlobalEntryCode(loc(module), module->statements, anyGlobalCode, genContext))) { outputDiagnostics(postponedMessages, 1); return mlir::failure(); @@ -694,6 +783,11 @@ namespace mlirgen outputDiagnostics(postponedMessages, 1); return mlir::failure(); } + + if (mlir::failed(createMemoryModelExportGlobalVar(genContext))) { + outputDiagnostics(postponedMessages, 1); + return mlir::failure(); + } } clearTempModule(); @@ -840,17 +934,42 @@ namespace mlirgen SmallVector symbolsAll; Dump::getSymbols(filePath, symbolsAll, stringAllocator); + StringRef memoryModelSymbol; for (auto symbol : symbolsAll) { if (symbol.starts_with(SHARED_LIB_DECLARATIONS_2UNDERSCORE)) { symbols.push_back(symbol); } + else if (symbol.starts_with(SHARED_LIB_MEMORY_MODEL)) + { + memoryModelSymbol = symbol; + } else if (symbol == MLIR_GCTORS) { mlirGctors = symbol; } } + + // "__tsmm___" - the model is the segment after the prefix. A library + // with no marker predates it, and everything did collect back then. + auto libraryModel = std::string("gc"); + if (!memoryModelSymbol.empty()) + { + auto rest = memoryModelSymbol.drop_front(StringRef(SHARED_LIB_MEMORY_MODEL).size()); + libraryModel = rest.take_until([](char c) { return c == '_'; }).str(); + } + + if (libraryModel != memoryModelName(compileOptions.memoryModel)) + { + // Allowed on purpose: an object arriving from a module managed differently is + // treated as immortal rather than rejected, so it leaks instead of being freed + // twice. See docs/reference-counting-evaluation.md section 4. + emitWarning(location) << "shared library '" << filePath << "' was built with -mm=" + << libraryModel << ", this module with -mm=" + << memoryModelName(compileOptions.memoryModel) + << ". Objects crossing between them are never reclaimed."; + } #else // only 1 file to load symbols.push_back(SHARED_LIB_DECLARATIONS_2UNDERSCORE); diff --git a/tslang/lib/TypeScript/MLIRGenStatements.cpp b/tslang/lib/TypeScript/MLIRGenStatements.cpp index d018cab6c..75308e97b 100644 --- a/tslang/lib/TypeScript/MLIRGenStatements.cpp +++ b/tslang/lib/TypeScript/MLIRGenStatements.cpp @@ -39,6 +39,19 @@ namespace mlirgen auto resultValue = V(result); if (resultValue) { + // A concise arrow body (`() => expr`) returns without going through the return + // statement, so it did not get the retain that makes every function hand back + // +1 (§9.24). There is no scope exit here to free the value, so this is not + // fixing a dangling read - it is the convention itself: a caller cannot be told + // "calls return owned" while one shape of function quietly returns borrowed. + // §9.27's classification checks for exactly this retain, so without it every + // arrow function would be excluded. + // + // Cast first, for the reason spelled out at the return statement below. + EXIT_IF_FAILED(castToDeclaredReturnType(loc(body), resultValue, genContext)) + + mlirGenRetainCaptured(loc(body), mlir::ValueRange{resultValue}); + return mlirGenReturnValue(loc(body), resultValue, false, genContext); } @@ -130,12 +143,45 @@ namespace mlirgen mlir::LogicalResult MLIRGenImpl::mlirGen(ts::Block blockAST, const GenContext &genContext, int skipStatements) { + // A `using` here needs disposal on the unwind path too, and only a TryOp has a + // landing pad to run that from - see mlirGenBlockWithUnwindCleanup and + // blockDeclaresUsing. Most blocks qualify: a function's own body, an if/loop body, a + // nested `{ }`, a hand-written try's own body, and - since the ToInvoke fix in + // Win32ExceptionPass - one that contains a nested using-scope of its own, which used + // to need a blockHasNestedUsing guard here. + // + // A `using` is not the only thing that needs the unwind path: on the Itanium path a + // typed `catch` leaves behind a rethrow block for the clause that did not match, and + // that is an exit the scope's owned locals have to be released before - see + // blockHasTypedCatch. Same wrapping, same cleanup region, different reason to want it. + // + // The two remaining conditions each guard against a real, still-open bug the wrapping + // would otherwise hit (see their comments). blockUsingInitializersAreAllNewExpr is + // about disposing a `using`, so it only has a say when there is one - but it keeps its + // veto in that case even if the typed-catch reason would have wrapped anyway, since + // what it guards against is the disposal, not the wrapping. A block that qualifies on + // neither count keeps the plain path below unchanged: no TryOp, no personality + // attribute, same IR as before this check existed. + auto declaresUsing = blockDeclaresUsing(blockAST, skipStatements); + auto usingIsDisposable = !declaresUsing || blockUsingInitializersAreAllNewExpr(blockAST, skipStatements); + auto rethrowNeedsCleanup = !compileOptions.isWindows && blockHasTypedCatch(blockAST, skipStatements); + if (usingIsDisposable && (declaresUsing || rethrowNeedsCleanup) && !blockIsInsideCatchOrFinally()) + { + return mlirGenBlockWithUnwindCleanup(blockAST, genContext, skipStatements); + } + auto location = loc(blockAST); SymbolTableScopeT varScope(symbolTable); GenContext genContextUsing(genContext); genContextUsing.parentBlockContext = &genContext; + // This block is the loop's own body scope exactly when the loop offered the marker to + // the context it handed down. Take it, and clear the offer so a block nested further in + // does not claim to be the loop as well - see GenContext::isLoopBodyScope. + genContextUsing.isLoopBodyScope = genContext.isLoop; + genContextUsing.isLoop = false; + DITableScopeT debugBlockScope(debugScope); if (compileOptions.generateDebugInfo && !blockAST->parent) { @@ -146,11 +192,97 @@ namespace mlirgen auto usingVars = std::make_unique>(); genContextUsing.usingVars = usingVars.get(); + auto ownedVars = std::make_unique>(); + genContextUsing.ownedVars = ownedVars.get(); + EXIT_IF_FAILED(mlirGenNoScopeVarsAndDisposable(blockAST, genContextUsing, skipStatements)); - // we need to call dispose for those which are in "using" + // we need to call dispose for those which are in "using", and to give up the + // references the block's own locals took // default value for genContext.cleanUpUsingVarsFlag = CurrentScope - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::CurrentScope, {}, &genContextUsing)); + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::CurrentScope, {}, &genContextUsing)); + + return mlir::success(); + } + + // The unwind-safe counterpart of mlirGen(Block): the same statements and the same + // dispose-on-exit, but wrapped in a catch-less TryOp so an exception passing through + // still runs the cleanup region before it keeps unwinding. Mirrors mlirGen(TryStatement)'s + // own try-body/cleanup handling - a real `try { using x = ...; } finally {}` already goes + // through that path and already disposes correctly on throw, which is what this reuses. + // Catches and finally stay empty: TryOpLowering erases an empty catches region and wires + // the cleanup block as a plain cleanup landing pad, so the exception is never caught here, + // only cleaned up after. + mlir::LogicalResult MLIRGenImpl::mlirGenBlockWithUnwindCleanup(ts::Block blockAST, const GenContext &genContext, + int skipStatements) + { + auto location = loc(blockAST); + + DITableScopeT debugBlockScope(debugScope); + if (compileOptions.generateDebugInfo && !blockAST->parent) + { + MLIRDebugInfoHelper mdi(builder, debugScope); + mdi.setLexicalBlock(location); + } + + mlir_ts::FuncOp funcOp = genContext.funcOp; + funcOp.setPersonalityAttr(builder.getBoolAttr(true)); + + auto tryOp = builder.create(location); + + GenContext tryGenContext(genContext); + tryGenContext.allocateScopeOwnedVarsOutsideOfOperation = true; + tryGenContext.currentOperation = tryOp; + + SmallVector types; + + builder.createBlock(&tryOp.getBody(), {}, types); + builder.createBlock(&tryOp.getCleanup(), {}, types); + builder.createBlock(&tryOp.getCatches(), {}, types); + builder.createBlock(&tryOp.getFinally(), {}, types); + + { + builder.setInsertionPointToStart(&tryOp.getBody().front()); + + SymbolTableScopeT varScope(symbolTable); + GenContext tryBodyGenContext(tryGenContext); + tryBodyGenContext.parentBlockContext = &tryGenContext; + + // as in mlirGen(Block): this is the block's own scope, so it is where a break or + // continue walking outwards stops + tryBodyGenContext.isLoopBodyScope = tryGenContext.isLoop; + tryBodyGenContext.isLoop = false; + + auto usingVars = std::make_unique>(); + tryBodyGenContext.usingVars = usingVars.get(); + + auto ownedVars = std::make_unique>(); + tryBodyGenContext.ownedVars = ownedVars.get(); + + EXIT_IF_FAILED(mlirGenNoScopeVarsAndDisposable(blockAST, tryBodyGenContext, skipStatements)); + + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::CurrentScopeKeepAfterUse, {}, &tryBodyGenContext)); + + builder.create(location); + + // cleanup: everything the body's scope owes, reached only from the unwind edge. The + // storage it names was hoisted out in front of the TryOp, so it dominates here as + // well as in the body. + builder.setInsertionPointToStart(&tryOp.getCleanup().front()); + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::CurrentScope, {}, &tryBodyGenContext)); + + builder.create(location); + } + + // no catch clause + builder.setInsertionPointToStart(&tryOp.getCatches().front()); + builder.create(location); + + // no finally block + builder.setInsertionPointToStart(&tryOp.getFinally().front()); + builder.create(location); + + builder.setInsertionPointAfter(tryOp); return mlir::success(); } @@ -306,20 +438,52 @@ namespace mlirgen auto expressionValue = V(result); if (!expressionValue) { - emitError(location, "No return value"); - } + // Nothing below can run without a value: the cast to the declared return type and + // the retain the return performs both read its type, and reading a null Value's + // type faults - silently, with no diagnostic, in MLIRGen (item 5aj). + // + // Reaching here during a discovery pass is ordinary rather than an error. A return + // expression can depend on something not registered yet - a sibling method's + // prototype, a class whose members are still being generated - and the statement + // loop comes back for it. That is why the report is conditional and the failure is + // not: the previous shape had them the other way round, reporting every time and + // returning only when the run was final, so a discovery pass carried the null + // forward instead of asking again. + if (!genContext.allowPartialResolve) + { + emitError(location, "No return value"); + } - if (!genContext.allowPartialResolve) - { - VALIDATE(expressionValue, location) + return mlir::failure(); } - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::FullStack, {}, &genContext)); + // The scope exit below releases every owned local in the frame, and the value being + // returned is very often held by one of them - `return x` after `let x = new C()` + // being the whole of it. Once an allocation is born unowned (§9.24) that local's + // release is the last one, so without this the value would be freed on the way out + // and the caller handed a dangling pointer. + // + // Retaining the value rather than trying to spot which local holds it is what makes + // this work for `return h.item`, `return arr[0]` and `return cond ? a : b` alike. It + // hands the caller a reference of its own, which is the same +1 transfer `pop` and + // `shift` perform (§9.22) - and, like those, one the caller does not yet consume. + // + // The cast to the declared return type comes first, so that the retain lands on the + // value the caller actually receives. mlirGenReturnValue below would otherwise cast + // afterwards and the reference would be left on whatever the return expression + // happened to evaluate to - a leak wherever the cast is a conversion, and nothing + // held at all where it allocates: `return { start: p, dir: d }` against a declared + // interface builds a heap block the tuple's own reference says nothing about. + EXIT_IF_FAILED(castToDeclaredReturnType(location, expressionValue, genContext)) + + mlirGenRetainCaptured(location, mlir::ValueRange{expressionValue}); + + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::FullStack, {}, &genContext)); return mlirGenReturnValue(location, expressionValue, false, genContext); } - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::FullStack, {}, &genContext)); + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::FullStack, {}, &genContext)); builder.create(location); return mlir::success(); @@ -786,7 +950,7 @@ namespace mlirgen auto label = MLIRHelper::getName(continueStatementAST->label); - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::LoopScope, label, &genContext)); + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::LoopScope, label, &genContext)); builder.create(location, builder.getStringAttr(label)); return mlir::success(); @@ -798,7 +962,7 @@ namespace mlirgen auto label = MLIRHelper::getName(breakStatementAST->label); - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::LoopScope, label, &genContext)); + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::LoopScope, label, &genContext)); builder.create(location, builder.getStringAttr(label)); return mlir::success(); @@ -930,7 +1094,7 @@ namespace mlirgen GenContext tryGenContext(genContext); // TODO: why do I need to allocate variables outside of "try" block? // well - short answer: to get access to vars in nested blocks for example 'cleanup' - tryGenContext.allocateUsingVarsOutsideOfOperation = true; + tryGenContext.allocateScopeOwnedVarsOutsideOfOperation = true; tryGenContext.currentOperation = tryOp; SmallVector types; @@ -949,22 +1113,30 @@ namespace mlirgen GenContext tryBodyGenContext(tryGenContext); tryBodyGenContext.parentBlockContext = &tryGenContext; + // as in mlirGen(Block): this is the block's own scope, so it is where a break or + // continue walking outwards stops + tryBodyGenContext.isLoopBodyScope = tryGenContext.isLoop; + tryBodyGenContext.isLoop = false; + auto usingVars = std::make_unique>(); tryBodyGenContext.usingVars = usingVars.get(); + auto ownedVars = std::make_unique>(); + tryBodyGenContext.ownedVars = ownedVars.get(); + auto result = mlirGenNoScopeVarsAndDisposable(tryStatementAST->tryBlock, tryBodyGenContext); EXIT_IF_FAILED(result) - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::CurrentScopeKeepAfterUse, {}, &tryBodyGenContext)); + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::CurrentScopeKeepAfterUse, {}, &tryBodyGenContext)); // terminator builder.create(location); // cleanup builder.setInsertionPointToStart(&tryOp.getCleanup().front()); - // we need to call dispose for those which are in "using" - // usingVars are empty here - EXIT_IF_FAILED(mlirGenDisposable(location, DisposeDepth::CurrentScope, {}, &tryBodyGenContext)); + // dispose what "using" declared and release what the body's locals took; their + // storage was hoisted out in front of the TryOp so it dominates this region too + EXIT_IF_FAILED(mlirGenScopeExit(location, DisposeDepth::CurrentScope, {}, &tryBodyGenContext)); // terminator builder.create(location); diff --git a/tslang/lib/TypeScript/MLIRGenVariables.cpp b/tslang/lib/TypeScript/MLIRGenVariables.cpp index adada6b46..2ad39a1de 100644 --- a/tslang/lib/TypeScript/MLIRGenVariables.cpp +++ b/tslang/lib/TypeScript/MLIRGenVariables.cpp @@ -85,6 +85,108 @@ namespace mlirgen return mlir::success(); } + // A local that holds a heap reference becomes an owner of it: it takes a reference here and + // gives it back at every exit from its scope (mlirGenReleaseOwned). Stated unconditionally + // - `ts.RetainSlot`/`ts.ReleaseSlot` erase whole under a collector, so a collected build + // sees no trace of this and cannot be broken by where the pair lands. + // + // The pair is balanced by construction, which is the property that makes this safe to add + // before anything consumes a count: the reference an allocation is born with is never given + // up here, so no release can outnumber its retains. It leaks rather than over-releases - + // and under `-mm=rc` the collector is still what reclaims, so the leak is inert. + // + // Deliberately excluded, each because the frame borrows the reference rather than owning + // it, and releasing one would drop a count nobody took: + // - globals, which outlive every scope; + // - parameters, owned by the caller - only variable declarations reach this; + // - captured variables held in the `this` context, whose slot belongs to the context; + // - const bindings with no storage, which have no slot to release from; + // - declarations with no initializer, whose slot holds nothing yet. A catch variable is + // the one that matters: it is declared here but written by the landing pad, so + // retaining at the declaration would read an uninitialized slot as a live reference. + // Consequently a `let s: string;` assigned later never becomes an owner - the + // assignment path below only fires on a slot this marked, so that stays balanced; + // - locals declared in a catch or finally clause. A release there is a call inside an + // exception funclet, and a call there is fragile independently of ownership: `catch (e: + // int) { new Res(); throw 2; }` crashes at run time with nothing of this involved. + // Rather than add a second way to reach it, those locals are not owned. + // + // The test itself is localTakesOwnership, shared with the hoisting decision in + // createLocalVariable so the two cannot disagree about which declarations these are. + // + // An excluded declaration is not finished with, though: it may still be captured, and a + // captured variable's *cell* is the scope's to give back whoever owns the value in it. That + // is trackPossibleCell below, and it is a different question with a different answer. + void MLIRGenImpl::takeOwnershipOfLocal(mlir::Location location, struct VariableDeclarationInfo &variableDeclarationInfo, + const GenContext &genContext) + { + if (!variableDeclarationInfo.storage || !localTakesOwnership(location, variableDeclarationInfo, genContext)) + { + trackPossibleCell(variableDeclarationInfo, genContext); + return; + } + + auto varOp = variableDeclarationInfo.storage.getDefiningOp(); + if (!varOp) + { + return; + } + + varOp->setAttr(OWNED_LOCAL_ATTR_NAME, builder.getUnitAttr()); + + // When the initializer already carries a reference for its receiver - `let x = new C()`, + // whose `C..new` retained the instance on the way out (§9.24) - taking another would put + // the slot one owner above the truth and nothing would ever be freed. Consume that + // reference instead: no retain here, and the scope-exit release is what gives it back. + // The pair stays balanced, so this cannot over-release; it is only the retain that moves. + // + // The attribute is what tells the verifier the release still has a partner, since there + // is no `ts.RetainSlot` left to pair it with. + if (producesOwnedReference(variableDeclarationInfo.initial)) + { + varOp->setAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME, builder.getUnitAttr()); + consumeOwnedReference(variableDeclarationInfo.initial); + } + else + { + builder.create(location, variableDeclarationInfo.storage); + } + + genContext.ownedVars->push_back(variableDeclarationInfo.storage); + } + + // A local that owns nothing still has to be listed, because it may yet become a cell. + // + // Whether a variable is captured is not known here: the closure that captures it is written + // after the declaration, and marks the storage when it is generated. Scope exit is generated + // after both, so that is where the question can be answered - all this does is make sure the + // slot is there to be asked about. Nothing is emitted for one that never becomes a cell. + // + // Two exclusions, and both are about where the release would land rather than about the + // variable. A declaration directly inside a try body has its release repeated in the + // cleanup region, which cannot see storage declared in the body - localTakesOwnership pairs + // its own answer with hoisting for exactly that reason, and hoisting every local of every + // try body is not a trade this makes. A catch or finally clause is excluded on the same + // terms takeOwnershipOfLocal excludes it. A captured variable declared in either keeps + // leaking its cell. + void MLIRGenImpl::trackPossibleCell(struct VariableDeclarationInfo &variableDeclarationInfo, + const GenContext &genContext) + { + if (genContext.ownedVars == nullptr || genContext.allocateScopeOwnedVarsOutsideOfOperation || + variableDeclarationInfo.isGlobal || variableDeclarationInfo.deleted || + variableDeclarationInfo.allocateInContextThis || blockIsInsideCatchOrFinally()) + { + return; + } + + if (!variableDeclarationInfo.storage || !variableDeclarationInfo.storage.getDefiningOp()) + { + return; + } + + genContext.ownedVars->push_back(variableDeclarationInfo.storage); + } + mlir::Type MLIRGenImpl::registerVariable(mlir::Location location, StringRef name, bool isFullName, VariableClass varClass, TypeValueInitFuncType func, const GenContext &genContext, bool showWarnings, bool forceLocalVar) { @@ -134,9 +236,12 @@ namespace mlirgen //LLVM_DEBUG(variableDeclarationInfo.printDebugInfo();); + takeOwnershipOfLocal(location, variableDeclarationInfo, genContext); + auto varDecl = variableDeclarationInfo.createVariableDeclaration(location, genContext); if (genContext.usingVars != nullptr && varDecl->getUsing()) { + varDecl->setDisposeGuard(variableDeclarationInfo.disposeGuard); genContext.usingVars->push_back(varDecl); } diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp new file mode 100644 index 000000000..ed5760913 --- /dev/null +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -0,0 +1,948 @@ +#include "mlir/Pass/Pass.h" + +#include "TypeScript/TypeScriptDialect.h" +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/Passes.h" +#include "TypeScript/Defines.h" +#include "TypeScript/MLIRLogic/MLIRTypeHelper.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/DenseSet.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/Support/Debug.h" + +#define DEBUG_TYPE "pass" + +namespace mlir_ts = mlir::typescript; + +namespace +{ + +// Lets a call take over the reference its callee returned, instead of retaining a second one. +// +// Every function retains its result on the way out (§9.24), so a receiver that retains it again +// - `let y = f()`, `h.item = f()` - is one owner above the truth and nothing is ever freed. The +// receiving sites already know how to consume such a value (§9.25); what they cannot know, at +// the point MLIRGen builds the call, is whether *this* callee is one that retains. Three things +// stop that being answerable there: the retain lives in the return statement, so other paths to +// a return reach it differently; the callee's FuncOp need not exist yet, which would make a +// lookup depend on declaration order; and a declared, imported or runtime callee looks identical +// to a local one while having no retaining return at all. +// +// All three dissolve once every function is present, which is why this is a pass rather than +// another marking site. It does not predict which callees retain - it looks. A function counts +// as returning owned only when every `ts.ReturnVal` of a heap-owning value in it is preceded by +// a `ts.Retain` of that same value. A callee with no body, a generator whose yields return +// without retaining, and anything else that does not match are simply not marked, and their +// callers go on retaining - which leaks rather than frees something live. The whole design puts +// the uncertain case on the leaking side. +// +// It runs in every memory model. The ops it removes erase on the way to LLVM under `gc` and +// `none` anyway, so removing them early keeps the IR the same shape in all three and keeps the +// ownership verifier checking one thing rather than two. +class OwnedReturnConsumptionPass + : public mlir::PassWrapper> +{ + CompileOptions compileOptions; + + public: + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(OwnedReturnConsumptionPass) + + OwnedReturnConsumptionPass(CompileOptions &compileOptions) : compileOptions(compileOptions) + { + } + + void runOnOperation() override + { + auto module = getOperation(); + MLIRTypeHelper mth(module->getContext(), compileOptions); + + llvm::DenseSet returnsOwned; + // Every method in the module, grouped by the name after the last dot: the candidate set + // for a virtual call, see virtualCallReturnsOwned. + llvm::StringMap> methodsByMemberName; + // Is there any function here at all that hands back a heap value without retaining it? + // See callThroughValueReturnsOwned. + auto anyUnclassifiedOwningReturn = false; + module.walk([&](mlir_ts::FuncOp funcOp) { + auto name = funcOp.getName(); + auto classified = functionReturnsOwned(mth, funcOp); + if (classified) + { + returnsOwned.insert(name); + } + + if (!classified) + { + for (auto resultType : funcOp.getFunctionType().getResults()) + { + if (mth.ownsHeapMemory(funcOp.getLoc(), resultType)) + { + anyUnclassifiedOwningReturn = true; + } + } + } + + auto dot = name.rfind('.'); + if (dot != mlir::StringRef::npos && dot + 1 < name.size()) + { + methodsByMemberName[name.substr(dot + 1)].push_back(name); + } + }); + + // What each vtable global puts in each of its slots, for the interface half of + // virtualCallReturnsOwned. Only slots initialised from a symbol are recorded; a slot that + // holds anything else - a field's offset, say - is simply absent, and absent means + // unclassifiable there rather than ignorable. + llvm::StringMap> vtableSlots; + module.walk([&](mlir_ts::GlobalOp globalOp) { + if (!globalOp.getSymName().ends_with(VTABLE_NAME)) + { + return; + } + + auto &slots = vtableSlots[globalOp.getSymName()]; + globalOp.walk([&](mlir_ts::InsertPropertyOp insertOp) { + auto position = insertOp.getPosition(); + if (position.size() != 1) + { + return; + } + + if (auto symbolRefOp = insertOp.getValue().getDefiningOp()) + { + slots[position[0]] = symbolRefOp.getIdentifier(); + } + }); + }); + + // `new C()` is marked where it is built, so there can be discarded temporaries to give + // back even when no function here is classified as returning owned. + if (returnsOwned.empty()) + { + releaseDiscardedTemporaries(mth, module); + return; + } + + llvm::SmallVector toErase; + module.walk([&](mlir_ts::CallIndirectOp callOp) { + if (callOp.getNumResults() != 1) + { + return; + } + + auto result = callOp.getResult(0); + if (!mth.ownsHeapMemory(callOp.getLoc(), result.getType())) + { + return; + } + + auto callee = calleeNameOf(callOp); + auto calleeReturnsOwned = !callee.empty() && returnsOwned.contains(callee); + + // A call through a plain value - a callback handed to `reduce`, a function-typed + // field - names nothing at all, and neither vtables nor member names help. What does + // help is that the question has a whole-module answer: if every function here that + // hands back a heap value retains it first, then every call that returns one hands + // back a reference, whatever it dispatched to. That is the +1 convention (§9.24) + // stated over the module rather than over one callee. + // + // One unclassified function anywhere turns this off for every indirect call, which is + // the conservative direction and the reason it is worth so little on its own and so + // much here: `reduce` calls its argument, and `raytrace.ts` builds its colours through + // `reduce`. An external function that returns a heap value counts as unclassified, + // so linking against anything whose returns cannot be seen switches it off too. + auto closedWorld = !anyUnclassifiedOwningReturn; + + if (!calleeReturnsOwned && !closedWorld && + !virtualCallReturnsOwned(callOp, returnsOwned, methodsByMemberName) && + !interfaceCallReturnsOwned(callOp, returnsOwned, vtableSlots)) + { + return; + } + + // Already settled at the point the call was built - `new C()` is marked there, + // where the callee is known outright. Nothing left to remove. + if (callOp->hasAttr(OWNED_RESULT_ATTR_NAME)) + { + return; + } + + if (auto *retain = findReceiverRetain(result)) + { + callOp->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + callOp->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + toErase.push_back(retain); + return; + } + + // Nobody took it. The +1 stands with no owner, which is the leak §9.30 closes - + // marked here, released below once every consumer has had its say. + callOp->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + }); + + for (auto *op : toErase) + { + op->erase(); + } + + consumeConditionalResults(mth, module); + + releaseDiscardedTemporaries(mth, module); + } + + // A conditional expression whose branches disagree about ownership. + // + // `cond ? a : f()` builds a `ts.If` with a result, and the two branches hand back references + // on different terms: `f()` produces a +1 that somebody must take over, while `a` is + // borrowed from wherever it already lives. The receiver outside can only do one thing, and + // what it does is retain - so the +1 from the call side is left with no owner at all. + // + // §9.30 cannot reach it, and says so in its own terms: the value's only user is `ts.Result`, + // a terminator, and releasing a value handed to a successor would free it while it is still + // live. So the call escapes the region unconsumed and the branch that allocates leaks every + // time it is taken. This is not a corner of the language - it is `x = cond ? y : f()` - and + // in `raytrace.ts` one such line held **18 of the 20 MB** reference counting had left on the + // table. Written as `if`/`else` with an assignment the same program reclaims everything, + // because then each branch stores into the slot and the store consumes. + // + // The fix is to make the branches agree before anyone outside looks: whichever branch + // borrows takes a reference of its own, so that every branch yields +1, and the `ts.If` + // result then carries a reference exactly the way a call's result does. From there the + // ordinary machinery applies - a receiver takes it over, or §9.30 gives it back at the end + // of the block. Retaining the borrowed side rather than releasing the owned one is what + // keeps this safe: there is no point inside the region at which the owned value could be + // released, because the receiver's retain has not happened yet. + void consumeConditionalResults(MLIRTypeHelper &mth, mlir::ModuleOp module) + { + llvm::SmallVector toErase; + + module.walk([&](mlir_ts::IfOp ifOp) { + if (ifOp.getNumResults() != 1) + { + return; + } + + auto result = ifOp.getResult(0); + if (!mth.ownsHeapMemory(ifOp.getLoc(), result.getType())) + { + return; + } + + // Already settled, either by this pass on an earlier op or where it was built. + if (ifOp->hasAttr(OWNED_RESULT_ATTR_NAME)) + { + return; + } + + // What each branch hands back. Every region has to yield exactly one value, or this + // is a shape that is not understood and is left alone. + llvm::SmallVector yields; + for (auto ®ion : ifOp->getRegions()) + { + if (region.empty()) + { + return; + } + + auto resultOp = mlir::dyn_cast_or_null(region.back().getTerminator()); + if (!resultOp || resultOp->getNumOperands() != 1) + { + return; + } + + yields.push_back(resultOp); + } + + if (yields.size() < 2) + { + return; + } + + // Only worth doing when some branch actually carries an unclaimed +1. Where every + // branch borrows, the receiver's retain is already the right and only answer. + auto carriesOwned = [&](mlir_ts::ResultOp resultOp) { + auto *definingOp = resultOp->getOperand(0).getDefiningOp(); + return definingOp && definingOp->hasAttr(OWNED_RESULT_ATTR_NAME) && + !definingOp->hasAttr(OWNED_RESULT_CONSUMED_ATTR_NAME) && + definingOp->getParentRegion() == resultOp->getParentRegion(); + }; + + if (llvm::none_of(yields, carriesOwned)) + { + return; + } + + for (auto resultOp : yields) + { + if (carriesOwned(resultOp)) + { + // The `ts.If` result now stands for this reference, so the producer's +1 has + // an owner and must not also be released as a discarded temporary. + resultOp->getOperand(0).getDefiningOp()->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, + mlir::UnitAttr::get(&getContext())); + continue; + } + + mlir::OpBuilder builder(resultOp); + builder.create(resultOp->getLoc(), resultOp->getOperand(0)); + } + + ifOp->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + + if (auto *retain = findReceiverRetain(result)) + { + ifOp->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + toErase.push_back(retain); + } + }); + + for (auto *op : toErase) + { + op->erase(); + } + } + + private: + // Gives back the +1 on a produced reference that no receiver ever took. + // + // Every function retains its result on the way out (§9.24), so a call hands back a reference + // whether or not the caller does anything with it. Where a receiver takes it over the pair is + // balanced (§9.25, §9.27); where nothing does, the reference stands with no owner and the + // value is never freed. That is not a corner case: `raytrace.ts` is built out of + // `Vector.plus(Vector.times(k, a), b)`, so nearly every allocation it makes is an + // intermediate passed straight as an argument, and it reclaimed nothing at all before this. + // + // WHERE the release goes is the whole difficulty - "after the last use" is not something + // MLIRGen can see while it is still building the expression. The answer here is the END OF + // THE PRODUCER'S OWN BLOCK, which is a temporary's natural lifetime (the enclosing statement, + // or one iteration of a loop body) and, more importantly, is unconditionally after every use + // in that block. Placing it after the last *user* instead looks tighter and is wrong: the + // receiver of `let x = f()` retains the result of the CAST, not of the call, so the call's + // last user is the cast and a release put there would run before that retain and free the + // value out from under it. End-of-block cannot get that ordering wrong. + // + // Two things disqualify a value, and both simply leave it leaking as before: + // + // - a user outside the producer's block, so the value outlives the block or is used on a + // path this cannot see; + // - a user that is a terminator, since a value handed to a successor as a block argument is + // still live after the point this would release it; + // - a `ts.StateLabel` after the definition, which is a generator's resume point: the state + // machine re-enters the block THERE, so the end of the block is reachable on a path that + // never ran the op that produced the value. Nothing about that is visible while the + // generator is still one block - it only becomes a use before definition once the state + // machine is expanded, and it surfaces as a dominance failure in the affine lowering + // rather than as anything this pass could notice. + // + // That bias is the same one the rest of this arc takes: an unreleased reference is invisible, + // a released one that was still owned is a use-after-free. + void releaseDiscardedTemporaries(MLIRTypeHelper &mth, mlir::ModuleOp module) + { + // Whole generators are left alone, the same way functionReturnsOwned leaves them alone + // and for the same reason: what a generator's body looks like here is not what runs. + // The state machine cuts its blocks at every resume point afterwards, so a position that + // is plainly after a definition now may not be on the path that reaches it later - and + // the failure is a dominance error in the affine lowering, not something visible here. + // Their temporaries leak, which is the side of the line this arc keeps everything + // uncertain on. + llvm::DenseSet generators; + module.walk([&](mlir_ts::FuncOp funcOp) { + auto isGenerator = false; + funcOp.walk([&](mlir_ts::YieldReturnValOp) { isGenerator = true; }); + if (isGenerator) + { + generators.insert(funcOp.getOperation()); + } + }); + + llvm::SmallVector discarded; + module.walk([&](mlir::Operation *op) { + if (!op->hasAttr(OWNED_RESULT_ATTR_NAME) || op->hasAttr(OWNED_RESULT_CONSUMED_ATTR_NAME)) + { + return; + } + + if (op->getNumResults() != 1 || !mth.ownsHeapMemory(op->getLoc(), op->getResult(0).getType())) + { + return; + } + + if (auto funcOp = op->getParentOfType()) + { + if (generators.contains(funcOp.getOperation())) + { + return; + } + } + + discarded.push_back(op); + }); + + mlir::OpBuilder builder(&getContext()); + for (auto *op : discarded) + { + if (!allUsesReleasableInOwnBlock(op)) + { + continue; + } + + auto *block = op->getBlock(); + + // The end of the block is not the only way out of it. A `return` written inside an + // `if` - which is where returns are usually written - leaves from a nested region and + // never reaches the release placed below, so the temporary was simply lost on that + // path. That is what `raytrace` was leaking: `addLightAt` builds a ray, tests it, and + // returns early when the light is blocked, so the ray's reference came back only on + // the path that falls through (section 9.60). + // + // Only exits that come after the definition in this block are covered, because only + // those are dominated by it. Nothing else has to be checked: a temporary whose value + // is used from another block is excluded by allUsesReleasableInOwnBlock above, so a + // nested exit cannot be returning this value or reading it. + // + // A `return` leaves for good wherever it is written. A `break` or `continue` only + // sometimes does, and which it is decides between a leak and a double release: + // + // - caught by a loop BELOW this block - the definition sits outside that loop - so + // control comes back and runs the end-of-block release as well. Releasing at the + // jump too would give the same reference back twice; + // - caught by a loop ABOVE this block, which is to say this block is that loop's + // body. The end-of-block release is then skipped for the iteration that jumps, + // and without one here the value is lost. + // + // jumpLeavesBlock answers it by walking from the jump up to this block and asking + // whether anything on the way catches it. + for (auto it = std::next(mlir::Block::iterator(op)); it != block->end(); ++it) + { + if (it->getNumRegions() == 0) + { + continue; + } + + it->walk([&](mlir::Operation *nested) { + // A nested function's `return` returns from that function, and its jumps are + // its own; nothing inside one is on a path out of this block. + if (mlir::isa(nested)) + { + return mlir::WalkResult::skip(); + } + + auto leavesBlock = mlir::isa(nested) || + (mlir::isa(nested) && + jumpLeavesBlock(nested, block)); + if (leavesBlock) + { + builder.setInsertionPoint(nested); + builder.create(op->getLoc(), op->getResult(0)); + } + + return mlir::WalkResult::advance(); + }); + } + + if (auto *exiting = firstExitingOpAfter(op)) + { + builder.setInsertionPoint(exiting); + } + else if (auto *terminator = block->getTerminator()) + { + builder.setInsertionPoint(terminator); + } + else + { + builder.setInsertionPointToEnd(block); + } + + builder.create(op->getLoc(), op->getResult(0)); + } + } + + // Does `op` catch this jump - is it the loop, switch or labelled statement the jump names? + // + // An unlabelled `break` is caught by the nearest enclosing loop or `switch`; an unlabelled + // `continue` by the nearest enclosing loop. A labelled one is caught by whatever carries that + // label, which may be several levels further out - `outer: while (..) { while (..) { break + // outer; } }` is the case that makes position alone the wrong question. + static bool catchesJump(mlir::Operation *op, bool isBreak, mlir::StringAttr label) + { + auto isLoop = mlir::isa(op); + auto isSwitch = mlir::isa(op); + + if (label && !label.getValue().empty()) + { + auto ownLabel = op->getAttrOfType(LABEL_ATTR_NAME); + return ownLabel && ownLabel.getValue() == label.getValue(); + } + + return isLoop || (isBreak && isSwitch); + } + + // Does this `break` or `continue` leave `block` for good, or does control come back into it? + // + // Walks outwards from the jump to the op that sits in `block`, asking each level whether it + // catches the jump. Nothing on the way - including that outermost op itself - means the jump + // is caught further out, so `block` is inside the loop being left and everything after the + // jump in it is skipped. + // + // Answers false when the walk runs out of ancestors without meeting `block`, which cannot + // happen for a jump this pass reached but is the safe answer either way: a missing release + // leaks, a surplus one frees memory twice. + static bool jumpLeavesBlock(mlir::Operation *jump, mlir::Block *block) + { + auto isBreak = mlir::isa(jump); + auto label = isBreak ? mlir::cast(jump).getLabelAttr() + : mlir::cast(jump).getLabelAttr(); + + for (auto *parent = jump->getParentOp(); parent != nullptr; parent = parent->getParentOp()) + { + if (catchesJump(parent, isBreak, label)) + { + return false; + } + + if (parent->getBlock() == block) + { + return true; + } + } + + return false; + } + + // Can a release at the end of this value's own block give its reference back safely? See + // releaseDiscardedTemporaries for what disqualifies a use and why. + static bool allUsesReleasableInOwnBlock(mlir::Operation *op) + { + auto *block = op->getBlock(); + for (auto *user : op->getResult(0).getUsers()) + { + if (user->getBlock() != block || user->hasTrait()) + { + return false; + } + } + + // a resume point between the definition and the end of the block - see above + for (auto it = std::next(mlir::Block::iterator(op)); it != block->end(); ++it) + { + if (mlir::isa(*it)) + { + return false; + } + } + + return true; + } + + // The first op after `op` that ends this block's execution, if there is one. + // + // "End of the block" is not the end of what runs. `ts.ReturnVal` is not a terminator - the + // scope-exit releases and `ts.Exit` follow it in the same block - but the affine lowering + // turns it into a branch to the exit block, and everything the pass appended after it is + // dropped as unreachable. A release placed there does not merely run late; it never runs. + // + // That is not a corner: any function whose block ends with a `return` has this shape, which + // is most of them. Before this, 36 of `raytrace.ts`'s 47 releases were discarded on the way + // to affine, so §9.30 was largely inert for exactly the code it was written for. The + // scope-exit releases MLIRGen emits never had the problem, because it emits them before + // building the return. + // + // Releasing before the return is right for a discarded temporary by definition: what is + // being returned was consumed by the return's own retain (§9.24) and so is not in this set. + static mlir::Operation *firstExitingOpAfter(mlir::Operation *op) + { + auto *block = op->getBlock(); + for (auto it = std::next(mlir::Block::iterator(op)); it != block->end(); ++it) + { + if (mlir::isa(*it)) + { + return &*it; + } + } + + return nullptr; + } + + static bool producesOwnedResult(mlir::Value value) + { + auto *definingOp = value.getDefiningOp(); + return definingOp && definingOp->hasAttr(OWNED_RESULT_ATTR_NAME); + } + + // The symbol a call names, when it names one. An indirect call through a value - a callback, + // a method off an interface, a function-typed field - answers empty and is left alone: there + // is no one callee to inspect, so the caller keeps its retain and leaks rather than guessing. + // + // A method call is not that. `obj.m(x)` builds a bound function and then splits it apart + // again - `GetMethod` for the code, `GetThis` for the receiver - so the callee is a step + // further back than a plain function's. The shapes below are the ones the dialect's own + // canonicalizer (SimplifyIndirectCallWithKnownCallee) already rewrites into direct calls, + // which is the argument that they name one callee: it is the same judgement, made here + // before canonicalization has run. + static mlir::StringRef calleeNameOf(mlir_ts::CallIndirectOp callOp) + { + if (callOp.getNumOperands() == 0) + { + return {}; + } + + auto callee = callOp.getOperand(0); + + if (auto symbolRefOp = callee.getDefiningOp()) + { + return symbolRefOp.getIdentifier(); + } + + // a non-virtual method, called without the bound-function detour + if (auto thisSymbolRefOp = callee.getDefiningOp()) + { + return thisSymbolRefOp.getIdentifier(); + } + + auto getMethodOp = callee.getDefiningOp(); + if (!getMethodOp) + { + return {}; + } + + auto boundFunc = getMethodOp.getBoundFunc(); + + if (auto thisSymbolRefOp = boundFunc.getDefiningOp()) + { + return thisSymbolRefOp.getIdentifier(); + } + + // A bound function built here from a known function - a trampoline - names it outright. + if (auto createBoundFunctionOp = boundFunc.getDefiningOp()) + { + if (auto symbolRefOp = createBoundFunctionOp.getFunc().getDefiningOp()) + { + return symbolRefOp.getIdentifier(); + } + + return {}; + } + + // `ts.ThisVirtualSymbolRef` is deliberately absent here. It carries an identifier, but + // that names the declaration the call was written against, not what the runtime class put + // in the slot - so reading it as *the* callee would consume a reference an override may + // never have taken. `private` looks like it would settle this and does not: this compiler + // accepts a subclass redeclaring a private method and dispatches to the override, where + // TypeScript rejects the program outright. See §9.32. + // + // Asking about every method that could be in the slot answers it instead - + // virtualCallReturnsOwned. + return {}; + } + + // A virtual call has no single callee, but it does have a set of possible ones, and the + // question this pass asks is answerable for a set: consume only if *every* candidate returns + // owned. One that does not leaves the call retaining, exactly as an unclassified callee does. + // + // The candidate set used here is every method in the module whose name after the last dot + // matches the call's - a superset of the overrides, since an override is `.` by construction, and a superset is the safe direction. It costs precision only + // where two unrelated classes share a method name and disagree about ownership, and the cost + // there is a leak rather than a free. + // + // Without this, `raytrace.ts` reclaimed about a quarter of what it allocated: nearly every + // call in it is a method call, so nearly every allocation it made kept the reference its + // callee's return had added. See section 9.53. + static bool virtualCallReturnsOwned(mlir_ts::CallIndirectOp callOp, + const llvm::DenseSet &returnsOwned, + const llvm::StringMap> &methodsByMemberName) + { + if (callOp.getNumOperands() == 0) + { + return false; + } + + auto getMethodOp = callOp.getOperand(0).getDefiningOp(); + if (!getMethodOp) + { + return false; + } + + auto virtualRefOp = getMethodOp.getBoundFunc().getDefiningOp(); + if (!virtualRefOp) + { + return false; + } + + auto identifier = virtualRefOp.getIdentifier(); + auto dot = identifier.rfind('.'); + if (dot == mlir::StringRef::npos || dot + 1 >= identifier.size()) + { + return false; + } + + auto candidates = methodsByMemberName.find(identifier.substr(dot + 1)); + if (candidates == methodsByMemberName.end() || candidates->second.empty()) + { + return false; + } + + for (auto candidate : candidates->second) + { + if (!returnsOwned.contains(candidate)) + { + return false; + } + } + + return true; + } + + // The same question for a call through an interface, where the member name is not enough to + // name the candidates: an interface method can be implemented by an object literal, whose + // function is named for where it was written (`Surfaces..feL166C18FH19436811`) rather than for + // the member it fills. Matching on the member name would miss it entirely, and missing a + // candidate is the direction that frees memory nobody retained. + // + // The vtables say it exactly. An interface value is built by `ts.NewInterface` over one of + // them, and every vtable in the module is a global: a class implementing `Thing` gets + // `Sphere.Thing..vtbl`, an object literal gets `Thing...vtbl`, and the interface's own + // name is a component of both. So the candidates for slot `index` of interface `I` are that + // slot in every vtable global naming `I`, and a slot that is absent - not initialised from a + // symbol - makes the whole call unclassifiable rather than being skipped. + static bool interfaceCallReturnsOwned(mlir_ts::CallIndirectOp callOp, + const llvm::DenseSet &returnsOwned, + const llvm::StringMap> &vtableSlots) + { + if (callOp.getNumOperands() == 0) + { + return false; + } + + auto getMethodOp = callOp.getOperand(0).getDefiningOp(); + if (!getMethodOp) + { + return false; + } + + auto interfaceRefOp = getMethodOp.getBoundFunc().getDefiningOp(); + if (!interfaceRefOp) + { + return false; + } + + auto interfaceType = dyn_cast(interfaceRefOp.getInterfaceVal().getType()); + if (!interfaceType) + { + return false; + } + + auto interfaceName = interfaceType.getName().getValue(); + auto index = (int64_t)interfaceRefOp.getIndex(); + + auto sawCandidate = false; + for (auto &vtable : vtableSlots) + { + if (!namesInterface(vtable.getKey(), interfaceName)) + { + continue; + } + + auto slot = vtable.getValue().find(index); + if (slot == vtable.getValue().end() || !returnsOwned.contains(slot->second)) + { + return false; + } + + sawCandidate = true; + } + + return sawCandidate; + } + + // Is `vtableName` a vtable for `interfaceName`? Both shapes spell the interface as a whole + // dot-separated component: `Sphere.Thing..vtbl` for a class that implements it, + // `Thing.19585545..vtbl` for an object literal that satisfies it. A class's own vtable, + // `Sphere..vtbl`, names no interface and is left out. + static bool namesInterface(mlir::StringRef vtableName, mlir::StringRef interfaceName) + { + auto rest = vtableName; + while (!rest.empty()) + { + auto split = rest.split('.'); + if (split.first == interfaceName) + { + return true; + } + + rest = split.second; + } + + return false; + } + + // Does every return of a heap-owning value in this function retain it first? + // + // Looked up rather than assumed, and answered "no" for anything unclear: a function with no + // A function this module only holds a declaration of: the definition is in another module, + // so there are no returns here to read and the body test above can never say yes. Refusing + // them all is what item 5al was - and it is not a small refusal. One imported method with a + // heap-returning signature also poisons the candidate set for its whole member name, so + // local classes' calls stop being consumed too. Measured on a two-module program whose work + // is an imported method returning a string, `rc` held 18.0 MB against `none`'s 18.0 and + // `gc`'s 5.7: reference counting reclaimed nothing whatsoever across a module boundary. + // + // Two things have to be true to say yes here, and both now are. + // + // First, it must be a tslang function rather than a foreign one. A `declare`d C function + // returning a `string` has no convention at all and must never be consumed; an imported + // tslang declaration carries `export`, re-printed from the exporting module's source, and a + // foreign one carries nothing. That is the whole test. + // + // Second, the defining module must return +1 (§9.24). If it was built `-mm=rc` it does: + // swept over the corpus, every one of the 36 exported functions with a body classifies as + // returning owned, and the only functions that fail the classification are `.next` methods + // of generator state objects, which are anonymous and internal and cannot be exported. If + // it was built `gc` or `none` it does not - but its blocks are now born + // HEAP_BLOCK_IMMORTAL, so the release this consumption adds is a no-op and the object + // leaks rather than being freed twice. That is section 4's agreed policy for a mixed link, + // and moving it into the object is what lets this say yes without asking which model the + // other module was built under - a question a static link cannot answer, because the import + // is resolved by re-parsing source before any artifact of it exists. + static bool importedFunctionReturnsOwned(MLIRTypeHelper &mth, mlir_ts::FuncOp funcOp) + { + if (!funcOp->hasAttr("export")) + { + return false; + } + + for (auto resultType : funcOp.getFunctionType().getResults()) + { + if (mth.ownsHeapMemory(funcOp.getLoc(), resultType)) + { + return true; + } + } + + return false; + } + + // body, a return whose retain is not in the same block, a return with no retain at all. A + // false "no" costs a leak; a false "yes" frees a value the callee never retained. + static bool functionReturnsOwned(MLIRTypeHelper &mth, mlir_ts::FuncOp funcOp) + { + if (funcOp.isExternal() || funcOp.getBody().empty()) + { + return importedFunctionReturnsOwned(mth, funcOp); + } + + // A generator's returns are not the value its caller receives - the caller gets the + // generator object, built by the transformation rather than by these returns. Rather + // than reason about what that transformation leaves behind, leave any function with a + // yield in it alone; its callers keep retaining, and leak. + auto isGenerator = false; + funcOp.walk([&](mlir_ts::YieldReturnValOp) { isGenerator = true; }); + if (isGenerator) + { + return false; + } + + auto sawOwningReturn = false; + auto everyReturnRetains = true; + funcOp.walk([&](mlir_ts::ReturnValOp returnOp) { + auto value = returnOp.getOperand(); + if (!mth.ownsHeapMemory(returnOp.getLoc(), value.getType())) + { + return; + } + + sawOwningReturn = true; + + // Two ways a return can hand back a reference. It retains one of its own, which is + // the ordinary case - or it forwards a reference it was already given, and then + // there is no retain to find: `return new C()` consumes the instance's own +1 + // rather than adding a second (§9.25). Reading only the first shape as "returns + // owned" left every `static times(...) { return new Vector(...) }` unclassified, + // which is most of what expression-shaped code is built from. + if (!retainPrecedes(returnOp, value) && !producesOwnedResult(value)) + { + everyReturnRetains = false; + } + }); + + return sawOwningReturn && everyReturnRetains; + } + + // Is there a `ts.Retain` of `value` ahead of `op` in its own block? The scope-exit releases + // sit between the two in the ordinary case, so this is not an adjacency test - but it stays + // inside one block deliberately. A retain somewhere else may not run on the path that + // reaches this return, and reading "retained" off a path that never retained is the one + // mistake here that frees live memory. + static bool retainPrecedes(mlir::Operation *op, mlir::Value value) + { + for (auto it = mlir::Block::iterator(op); it != op->getBlock()->begin();) + { + --it; + if (auto retainOp = mlir::dyn_cast(*it)) + { + if (retainOp.getReference() == value) + { + return true; + } + } + } + + return false; + } + + // The retain a receiver put on this call's result, if it took one. Two shapes, matching the + // two ways §9.25's receivers acquire: a `ts.Retain` on the value itself (a field or element + // store, a literal capturing it, a return passing it on), and a `ts.RetainSlot` on the + // storage of a local declared from it. + // + // Returning null is the ordinary answer for a result nobody took - `f();` on its own, or + // `f().n` - and it is left exactly as it is. Consuming a reference no receiver balances + // would free the value while the expression is still using it. + static mlir::Operation *findReceiverRetain(mlir::Value result) + { + for (auto *user : result.getUsers()) + { + if (auto retainOp = mlir::dyn_cast(user)) + { + if (retainOp.getReference() == result) + { + return retainOp.getOperation(); + } + } + } + + for (auto *user : result.getUsers()) + { + auto varOp = mlir::dyn_cast(user); + if (!varOp || !varOp->hasAttr(OWNED_LOCAL_ATTR_NAME) || + varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME)) + { + continue; + } + + for (auto *slotUser : varOp.getResult().getUsers()) + { + if (auto retainSlotOp = mlir::dyn_cast(slotUser)) + { + // the declaration becomes the acquisition, which is what keeps the + // ownership verifier able to pair the release still to come + varOp->setAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(varOp.getContext())); + return retainSlotOp.getOperation(); + } + } + } + + return nullptr; + } +}; + +} // end anonymous namespace + +#undef DEBUG_TYPE + +/// Create pass. +std::unique_ptr mlir_ts::createOwnedReturnConsumptionPass(CompileOptions &compileOptions) +{ + return std::make_unique(compileOptions); +} diff --git a/tslang/lib/TypeScript/OwnershipVerifierPass.cpp b/tslang/lib/TypeScript/OwnershipVerifierPass.cpp new file mode 100644 index 000000000..8eb433bac --- /dev/null +++ b/tslang/lib/TypeScript/OwnershipVerifierPass.cpp @@ -0,0 +1,292 @@ +#include "mlir/Pass/Pass.h" + +#include "TypeScript/TypeScriptDialect.h" +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/TypeScriptFunctionPass.h" +#include "TypeScript/Passes.h" +#include "TypeScript/Defines.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/DenseSet.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/Support/Debug.h" + +#define DEBUG_TYPE "pass" + +namespace mlir_ts = mlir::typescript; + +namespace +{ + +// Checks the invariant ownership insertion is built on: a slot that takes a reference gives it +// back on every path out of the function, unwind paths included. +// +// This runs at the affine level, after TryOpLowering has turned scopes into blocks, because +// that is the first point where the unwind paths are ordinary CFG edges and can be walked like +// any other. It runs in every memory model, not just `-mm=rc`: ts.RetainSlot and ts.ReleaseSlot +// survive to here regardless and are only erased on the way to LLVM, so a collected build +// checks the same invariant a counted one does. That matters - most of the suite, and most of +// CI, is collected. +// +// It deliberately checks the direction that leaks rather than the direction that frees live +// memory. Step 5a's insertion is balanced by construction (retain at the declaration, release +// at every scope exit), so an unmatched release cannot currently be generated; what an +// extension to fields, elements, arguments or returns will get wrong first is a path out that +// nobody released on. The cheap structural half of the other direction is here too: a release +// naming a slot that is never retained. +class OwnershipVerifierPass : public mlir::PassWrapper +{ + public: + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(OwnershipVerifierPass) + + void runOnFunction() override + { + auto f = getFunction(); + + // Every point at which a slot acquires a reference, paired with the operation to blame + // if it is not given back. Two shapes reach this list, and keeping both in one list is + // the point: a `ts.RetainSlot`, and a declaration that took ownership by consuming its + // initializer's already-owned reference instead of retaining (§9.25). The second has no + // retain operation at all, so treating "acquisition" as a synonym for `ts.RetainSlot` + // would quietly stop checking exactly the locals whose release now matters most. + llvm::SmallVector> acquisitions; + llvm::DenseSet acquiredSlots; + llvm::SmallVector releases; + f.walk([&](mlir::Operation *op) { + if (auto retainOp = mlir::dyn_cast(op)) + { + acquisitions.emplace_back(retainOp.getSlot(), retainOp.getOperation()); + acquiredSlots.insert(retainOp.getSlot()); + } + else if (auto releaseOp = mlir::dyn_cast(op)) + { + releases.push_back(releaseOp); + } + else if (auto varOp = mlir::dyn_cast(op)) + { + if (varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME)) + { + acquisitions.emplace_back(varOp.getResult(), varOp.getOperation()); + acquiredSlots.insert(varOp.getResult()); + } + } + }); + + if (acquisitions.empty() && releases.empty()) + { + return; + } + + for (auto releaseOp : releases) + { + if (!acquiredSlots.contains(releaseOp.getSlot()) && !isHandOver(releaseOp)) + { + releaseOp.emitError("ownership: this slot is released but never retained"); + signalPassFailure(); + } + } + + for (auto [slot, acquireOp] : acquisitions) + { + verifyReleasedOnEveryPath(f, slot, acquireOp); + } + } + + private: + // Is this release the giving-up half of an overwrite, rather than an unmatched release? + // + // An assignment into owning storage hands the count over: `ts.Retain` on the value coming + // in, `ts.ReleaseSlot` on what the slot still holds, then the store. The two halves are not + // expressed on the same thing - the retain names a value, the release names a slot - so the + // slot never appears in a `ts.RetainSlot` and the plain "released but never retained" test + // reports every field store in the suite. Recognising the store that follows is what tells + // the two apart: a release about to be overwritten is a hand-over, and the reference it + // gives up was taken by whoever stored the old value. + static bool isHandOver(mlir_ts::ReleaseSlotOp releaseOp) + { + auto slot = releaseOp.getSlot(); + for (auto it = std::next(releaseOp->getIterator()); it != releaseOp->getBlock()->end(); ++it) + { + if (auto storeOp = mlir::dyn_cast(*it)) + { + if (storeOp.getReference() == slot) + { + return true; + } + } + + // another release of the same slot first means this one was not the overwrite's + if (auto otherRelease = mlir::dyn_cast(*it)) + { + if (otherRelease.getSlot() == slot) + { + return false; + } + } + } + + return false; + } + + // Does this operation give back what `slot` took? Either release does. A `ts.ReleaseCell` + // gives up a captured variable's whole cell, and the value in the cell goes with it when + // the last owner lets go - so a captured local's scope exit discharges its declaration + // exactly as a `ts.ReleaseSlot` does, and reading only the latter would report every + // captured local in the suite as a leak. + static bool releasesSlot(mlir::Operation *op, mlir::Value slot) + { + if (auto releaseOp = mlir::dyn_cast(op)) + { + return releaseOp.getSlot() == slot; + } + + if (auto releaseCellOp = mlir::dyn_cast(op)) + { + return releaseCellOp.getSlot() == slot; + } + + return false; + } + + // Whether this block gives the slot back - directly, or inside a region of one of its own + // operations. Nested regions count as releasing rather than as opaque: reporting a leak + // that the IR does pay, somewhere this walk does not follow, would be the one kind of + // failure that makes a verifier get switched off. + static bool blockReleases(mlir::Block *block, mlir::Value slot) + { + auto found = false; + for (auto &op : *block) + { + op.walk([&](mlir::Operation *inner) { + if (releasesSlot(inner, slot)) + { + found = true; + } + }); + + if (found) + { + return true; + } + } + + return false; + } + + // A terminator that leaves the function: ts.ReturnInternal, and the abrupt exits that carry + // no successor of their own, such as a throw with nothing in this function to catch it. + static bool blockExitsFunction(mlir::Block *block) + { + auto *terminator = block->getTerminator(); + return terminator != nullptr && terminator->getNumSuccessors() == 0; + } + + void verifyReleasedOnEveryPath(mlir_ts::FuncOp f, mlir::Value slot, mlir::Operation *acquireOp) + { + auto *retainBlock = acquireOp->getBlock(); + auto *region = retainBlock->getParent(); + if (region == nullptr) + { + return; + } + + // releasedFromStart[B]: every path from the start of B to a function exit passes a + // release of this slot. A backward must-analysis, so it starts optimistic and is driven + // down to a fixed point - which leaves a loop with no exit at all reading as satisfied, + // correctly: it has no path to an exit to leak on. + llvm::DenseMap releasedFromStart; + llvm::DenseMap hasRelease; + for (auto &block : *region) + { + hasRelease[&block] = blockReleases(&block, slot); + releasedFromStart[&block] = true; + } + + auto changed = true; + while (changed) + { + changed = false; + for (auto &block : *region) + { + auto released = true; + if (hasRelease[&block]) + { + released = true; + } + else if (blockExitsFunction(&block)) + { + released = false; + } + else + { + for (auto *successor : block.getSuccessors()) + { + auto it = releasedFromStart.find(successor); + if (it != releasedFromStart.end() && !it->second) + { + released = false; + break; + } + } + } + + if (releasedFromStart[&block] != released) + { + releasedFromStart[&block] = released; + changed = true; + } + } + } + + // The acquiring block is special: only what follows the acquisition in it counts, since + // a release ahead of it belongs to some earlier trip round a loop. + auto releasedAfterRetain = false; + for (auto it = std::next(acquireOp->getIterator()); it != retainBlock->end(); ++it) + { + it->walk([&](mlir::Operation *inner) { + if (releasesSlot(inner, slot)) + { + releasedAfterRetain = true; + } + }); + } + + if (releasedAfterRetain) + { + return; + } + + if (blockExitsFunction(retainBlock)) + { + reportLeak(acquireOp); + return; + } + + for (auto *successor : retainBlock->getSuccessors()) + { + auto it = releasedFromStart.find(successor); + if (it != releasedFromStart.end() && !it->second) + { + reportLeak(acquireOp); + return; + } + } + } + + void reportLeak(mlir::Operation *acquireOp) + { + acquireOp->emitError("ownership: this slot takes a reference that some path out of the " + "function never gives back"); + signalPassFailure(); + } +}; + +} // end anonymous namespace + +#undef DEBUG_TYPE + +/// Create pass. +std::unique_ptr mlir_ts::createOwnershipVerifierPass() +{ + return std::make_unique(); +} diff --git a/tslang/lib/TypeScript/TypeScriptDialect.cpp b/tslang/lib/TypeScript/TypeScriptDialect.cpp index 5e8e593dd..e36003aec 100644 --- a/tslang/lib/TypeScript/TypeScriptDialect.cpp +++ b/tslang/lib/TypeScript/TypeScriptDialect.cpp @@ -297,6 +297,24 @@ struct TypeScriptInlinerInterface : public mlir::DialectInlinerInterface } } + /// The inliner has a fast path for a callee that is a single block: it hands the block's + /// terminator to handleTerminator and then erases it outright, on the assumption that a + /// terminator is return-like and its operands are all it had left to say. `ts.ThrowCall` + /// is a terminator too, and erasing one deletes the throw - the caller then carries on as + /// if the callee had returned, which is how a function whose whole body is `throw` came to + /// inline down to nothing. Only a return survives that path, so send everything else down + /// the multi-block one, which leaves the terminator where it is. + bool allowSingleBlockOptimization(mlir::iterator_range inlinedBlocks) const final + { + if (!llvm::hasSingleElement(inlinedBlocks)) + { + // the fast path is not taken anyway; the answer does not matter + return true; + } + + return isa(inlinedBlocks.begin()->getTerminator()); + } + /// Attempts to materialize a conversion for a type mismatch between a call /// from this dialect, and a callable region. This method should generate an /// operation that takes 'input' as the only operand, and produces a single diff --git a/tslang/lib/TypeScript/TypeScriptOps.cpp b/tslang/lib/TypeScript/TypeScriptOps.cpp index 29a9b59b9..f01497741 100644 --- a/tslang/lib/TypeScript/TypeScriptOps.cpp +++ b/tslang/lib/TypeScript/TypeScriptOps.cpp @@ -828,11 +828,25 @@ bool mlir_ts::CastOp::areCastCompatible(TypeRange inputs, TypeRange outputs) // effects here (as the previous blanket `Pure` trait did) let generic CSE - which runs on the // `ts` dialect before this op is ever lowered to the actual allocation call - treat two such // casts with the same (identical, CSE'd) constant operand as redundant and merge them into one, -// silently aliasing what should be two distinct backing arrays. All other CastOp shapes are true -// value-preserving casts with no allocation, so they keep reporting no effects. +// silently aliasing what should be two distinct backing arrays. +// +// Casting anything to AnyType is the second allocating shape, and for the same reason: the +// lowering boxes the value, and CastLogicHelper::castToAny always calls MemoryAlloc. Merging two +// identical boxing casts is what made `00mixed_type_ops.ts` a double free under `-mm=rc` - `a = +// true` twice with another value in between produced two structurally identical casts, so the +// second assignment stored a pointer to the box the first had already released, and the block was +// freed once there and again at the end of the block. It is inert under `gc` (a box is immutable +// and equality unboxes rather than comparing box pointers, so an alias is unobservable and nothing +// frees it) but it is an allocation being reported as pure either way. See +// docs/reference-counting-evaluation.md section 9.48. +// +// All other CastOp shapes are true value-preserving casts with no allocation, so they keep +// reporting no effects. void mlir_ts::CastOp::getEffects(SmallVectorImpl> &effects) { - if (isa(getIn().getType()) && isa(getRes().getType())) + auto allocates = (isa(getIn().getType()) && isa(getRes().getType())) || + isa(getRes().getType()); + if (allocates) { auto result = cast(getRes()); effects.emplace_back(MemoryEffects::Allocate::get(), result); diff --git a/tslang/lib/TypeScriptAsyncRuntime/AsyncRuntime.cpp b/tslang/lib/TypeScriptAsyncRuntime/AsyncRuntime.cpp index bb5a856eb..3adce0ad8 100644 --- a/tslang/lib/TypeScriptAsyncRuntime/AsyncRuntime.cpp +++ b/tslang/lib/TypeScriptAsyncRuntime/AsyncRuntime.cpp @@ -9,8 +9,11 @@ // //===----------------------------------------------------------------------===// // -// This file implements basic Async runtime API for supporting Async dialect -// to LLVM dialect lowering. +// Built into the static TypeScriptAsyncRuntime library, linked directly into +// AOT-compiled executables. The implementation itself lives in +// AsyncRuntimeCommon.inc, shared with TypeScriptRuntime (the shared, JIT-loaded +// build of the same API); this file adds the MSVC `aligned_alloc` shim on top +// of it. // //===----------------------------------------------------------------------===// @@ -19,536 +22,32 @@ #ifdef MLIR_ASYNCRUNTIME_DEFINE_FUNCTIONS -#include -#include -#include -#include -#include -#include -#include -#include - -#include "llvm/ADT/StringMap.h" -#include "llvm/Support/ThreadPool.h" - -using namespace mlir::runtime; +#include "../AsyncRuntimeCommon.inc" +#ifdef _WIN32 //===----------------------------------------------------------------------===// -// Async runtime API. +// `aligned_alloc` for a platform that does not have one. //===----------------------------------------------------------------------===// -namespace mlir -{ -namespace runtime +// The coroutine lowering allocates an async function's frame by calling `aligned_alloc` by name +// and releases it with plain `free`. MSVC has no `aligned_alloc`, so an executable that awaits +// anything does not link at all unless the model rewrites the call (which only `-mm=gc` does, +// to GC_memalign). `_aligned_malloc` is not a stand-in: its memory may only go back through +// `_aligned_free`, and pairing it with `free` corrupts the CRT heap. Windows' `malloc` is +// already aligned enough for every request anything makes - the frame asks for 8 - so it serves +// the request and keeps the pairing honest. +extern "C" void *aligned_alloc(size_t alignment, size_t size) { -namespace -{ - - // Forward declare class defined below. - class RefCounted; - - // -------------------------------------------------------------------------- // - // AsyncRuntime orchestrates all async operations and Async runtime API is built - // on top of the default runtime instance. - // -------------------------------------------------------------------------- // - - class AsyncRuntime - { - public: - AsyncRuntime() : numRefCountedObjects(0) - { - } - - ~AsyncRuntime() - { - threadPool.wait(); // wait for the completion of all async tasks - assert(getNumRefCountedObjects() == 0 && "all ref counted objects must be destroyed"); - } - - int64_t getNumRefCountedObjects() - { - return numRefCountedObjects.load(std::memory_order_relaxed); - } - - llvm::ThreadPoolInterface &getThreadPool() - { - return threadPool; - } - - private: - friend class RefCounted; - - // Count the total number of reference counted objects in this instance - // of an AsyncRuntime. For debugging purposes only. - void addNumRefCountedObjects() - { - numRefCountedObjects.fetch_add(1, std::memory_order_relaxed); - } - void dropNumRefCountedObjects() - { - numRefCountedObjects.fetch_sub(1, std::memory_order_relaxed); - } - - std::atomic numRefCountedObjects; - llvm::DefaultThreadPool threadPool; - }; - - // -------------------------------------------------------------------------- // - // A state of the async runtime value (token, value or group). - // -------------------------------------------------------------------------- // - - class State - { - public: - enum StateEnum : int8_t - { - // The underlying value is not yet available for consumption. - kUnavailable = 0, - // The underlying value is available for consumption. This state can not - // transition to any other state. - kAvailable = 1, - // This underlying value is available and contains an error. This state can - // not transition to any other state. - kError = 2, - }; - - /* implicit */ State(StateEnum s) : state(s) - { - } - /* implicit */ operator StateEnum() - { - return state; - } - - bool isUnavailable() const - { - return state == kUnavailable; - } - bool isAvailable() const - { - return state == kAvailable; - } - bool isError() const - { - return state == kError; - } - bool isAvailableOrError() const - { - return isAvailable() || isError(); - } - - const char *debug() const - { - switch (state) - { - case kUnavailable: - return "unavailable"; - case kAvailable: - return "available"; - case kError: - return "error"; - } - } - - private: - StateEnum state; - }; - - // -------------------------------------------------------------------------- // - // A base class for all reference counted objects created by the async runtime. - // -------------------------------------------------------------------------- // - - class RefCounted - { - public: - RefCounted(AsyncRuntime *runtime, int64_t refCount = 1) : runtime(runtime), refCount(refCount) - { - runtime->addNumRefCountedObjects(); - } - - virtual ~RefCounted() - { - assert(refCount.load() == 0 && "reference count must be zero"); - runtime->dropNumRefCountedObjects(); - } - - RefCounted(const RefCounted &) = delete; - RefCounted &operator=(const RefCounted &) = delete; - - void addRef(int64_t count = 1) - { - refCount.fetch_add(count); - } - - void dropRef(int64_t count = 1) - { - int64_t previous = refCount.fetch_sub(count); - assert(previous >= count && "reference count should not go below zero"); - if (previous == count) - destroy(); - } - - protected: - virtual void destroy() - { - delete this; - } - - private: - AsyncRuntime *runtime; - std::atomic refCount; - }; - -} // namespace - -// Returns the default per-process instance of an async runtime. -static std::unique_ptr &getDefaultAsyncRuntimeInstance() -{ - static auto runtime = std::make_unique(); - return runtime; -} - -static void resetDefaultAsyncRuntime() -{ - return getDefaultAsyncRuntimeInstance().reset(); -} - -static AsyncRuntime *getDefaultAsyncRuntime() -{ - return getDefaultAsyncRuntimeInstance().get(); -} - -// Async token provides a mechanism to signal asynchronous operation completion. -struct AsyncToken : public RefCounted -{ - // AsyncToken created with a reference count of 2 because it will be returned - // to the `async.execute` caller and also will be later on emplaced by the - // asynchronously executed task. If the caller immediately will drop its - // reference we must ensure that the token will be alive until the - // asynchronous operation is completed. - AsyncToken(AsyncRuntime *runtime) : RefCounted(runtime, /*refCount=*/2), state(State::kUnavailable) + // what MSVC's `malloc` guarantees: enough for any fundamental type, 16 bytes on x64 + constexpr size_t mallocAlignment = 2 * sizeof(void *); + if (alignment > mallocAlignment) { + std::cerr << "tslang runtime: alignment of " << alignment << " requested, only " << mallocAlignment + << " is available" << std::endl; } - std::atomic state; - - // Pending awaiters are guarded by a mutex. - std::mutex mu; - std::condition_variable cv; - std::vector> awaiters; -}; - -// Async value provides a mechanism to access the result of asynchronous -// operations. It owns the storage that is used to store/load the value of the -// underlying type, and a flag to signal if the value is ready or not. -struct AsyncValue : public RefCounted -{ - // AsyncValue similar to an AsyncToken created with a reference count of 2. - AsyncValue(AsyncRuntime *runtime, int64_t size) - : RefCounted(runtime, /*refCount=*/2), state(State::kUnavailable), storage(size) - { - } - - std::atomic state; - - // Use vector of bytes to store async value payload. - std::vector storage; - - // Pending awaiters are guarded by a mutex. - std::mutex mu; - std::condition_variable cv; - std::vector> awaiters; -}; - -// Async group provides a mechanism to group together multiple async tokens or -// values to await on all of them together (wait for the completion of all -// tokens or values added to the group). -struct AsyncGroup : public RefCounted -{ - AsyncGroup(AsyncRuntime *runtime, int64_t size) : RefCounted(runtime), pendingTokens(size), numErrors(0), rank(0) - { - } - - std::atomic pendingTokens; - std::atomic numErrors; - std::atomic rank; - - // Pending awaiters are guarded by a mutex. - std::mutex mu; - std::condition_variable cv; - std::vector> awaiters; -}; - -// Adds references to reference counted runtime object. -extern "C" void mlirAsyncRuntimeAddRef(RefCountedObjPtr ptr, int64_t count) -{ - RefCounted *refCounted = static_cast(ptr); - refCounted->addRef(count); -} - -// Drops references from reference counted runtime object. -extern "C" void mlirAsyncRuntimeDropRef(RefCountedObjPtr ptr, int64_t count) -{ - RefCounted *refCounted = static_cast(ptr); - refCounted->dropRef(count); -} - -// Creates a new `async.token` in not-ready state. -extern "C" AsyncToken *mlirAsyncRuntimeCreateToken() -{ - AsyncToken *token = new AsyncToken(getDefaultAsyncRuntime()); - return token; + return malloc(size); } - -// Creates a new `async.value` in not-ready state. -extern "C" AsyncValue *mlirAsyncRuntimeCreateValue(int64_t size) -{ - AsyncValue *value = new AsyncValue(getDefaultAsyncRuntime(), size); - return value; -} - -// Create a new `async.group` in empty state. -extern "C" AsyncGroup *mlirAsyncRuntimeCreateGroup(int64_t size) -{ - AsyncGroup *group = new AsyncGroup(getDefaultAsyncRuntime(), size); - return group; -} - -extern "C" int64_t mlirAsyncRuntimeAddTokenToGroup(AsyncToken *token, AsyncGroup *group) -{ - std::unique_lock lockToken(token->mu); - std::unique_lock lockGroup(group->mu); - - // Get the rank of the token inside the group before we drop the reference. - int rank = group->rank.fetch_add(1); - - // HACK: ASD: to support dynamic size - group->pendingTokens.fetch_add(1); - - auto onTokenReady = [group, token]() - { - // Increment the number of errors in the group. - if (State(token->state).isError()) - group->numErrors.fetch_add(1); - - // If pending tokens go below zero it means that more tokens than the group - // size were added to this group. - assert(group->pendingTokens > 0 && "wrong group size"); - - // Run all group awaiters if it was the last token in the group. - if (group->pendingTokens.fetch_sub(1) == 1) - { - group->cv.notify_all(); - for (auto &awaiter : group->awaiters) - awaiter(); - } - }; - - if (State(token->state).isAvailableOrError()) - { - // Update group pending tokens immediately and maybe run awaiters. - onTokenReady(); - } - else - { - // Update group pending tokens when token will become ready. Because this - // will happen asynchronously we must ensure that `group` is alive until - // then, and re-ackquire the lock. - group->addRef(); - - token->awaiters.emplace_back([group, onTokenReady]() - { - // Make sure that `dropRef` does not destroy the mutex owned by the lock. - { - std::unique_lock lockGroup(group->mu); - onTokenReady(); - } - group->dropRef(); }); - } - - return rank; -} - -// Switches `async.token` to available or error state (terminatl state) and runs -// all awaiters. -static void setTokenState(AsyncToken *token, State state) -{ - assert(state.isAvailableOrError() && "must be terminal state"); - assert(State(token->state).isUnavailable() && "token must be unavailable"); - - // Make sure that `dropRef` does not destroy the mutex owned by the lock. - { - std::unique_lock lock(token->mu); - token->state = state; - token->cv.notify_all(); - for (auto &awaiter : token->awaiters) - awaiter(); - } - - // Async tokens created with a ref count `2` to keep token alive until the - // async task completes. Drop this reference explicitly when token emplaced. - token->dropRef(); -} - -static void setValueState(AsyncValue *value, State state) -{ - assert(state.isAvailableOrError() && "must be terminal state"); - assert(State(value->state).isUnavailable() && "value must be unavailable"); - - // Make sure that `dropRef` does not destroy the mutex owned by the lock. - { - std::unique_lock lock(value->mu); - value->state = state; - value->cv.notify_all(); - for (auto &awaiter : value->awaiters) - awaiter(); - } - - // Async values created with a ref count `2` to keep value alive until the - // async task completes. Drop this reference explicitly when value emplaced. - value->dropRef(); -} - -extern "C" void mlirAsyncRuntimeEmplaceToken(AsyncToken *token) -{ - setTokenState(token, State::kAvailable); -} - -extern "C" void mlirAsyncRuntimeEmplaceValue(AsyncValue *value) -{ - setValueState(value, State::kAvailable); -} - -extern "C" void mlirAsyncRuntimeSetTokenError(AsyncToken *token) -{ - setTokenState(token, State::kError); -} - -extern "C" void mlirAsyncRuntimeSetValueError(AsyncValue *value) -{ - setValueState(value, State::kError); -} - -extern "C" bool mlirAsyncRuntimeIsTokenError(AsyncToken *token) -{ - return State(token->state).isError(); -} - -extern "C" bool mlirAsyncRuntimeIsValueError(AsyncValue *value) -{ - return State(value->state).isError(); -} - -extern "C" bool mlirAsyncRuntimeIsGroupError(AsyncGroup *group) -{ - return group->numErrors.load() > 0; -} - -extern "C" void mlirAsyncRuntimeAwaitToken(AsyncToken *token) -{ - std::unique_lock lock(token->mu); - if (!State(token->state).isAvailableOrError()) - token->cv.wait(lock, [token] - { return State(token->state).isAvailableOrError(); }); -} - -extern "C" void mlirAsyncRuntimeAwaitValue(AsyncValue *value) -{ - std::unique_lock lock(value->mu); - if (!State(value->state).isAvailableOrError()) - value->cv.wait(lock, [value] - { return State(value->state).isAvailableOrError(); }); -} - -extern "C" void mlirAsyncRuntimeAwaitAllInGroup(AsyncGroup *group) -{ - std::unique_lock lock(group->mu); - if (group->pendingTokens != 0) - group->cv.wait(lock, [group] - { return group->pendingTokens == 0; }); -} - -// Returns a pointer to the storage owned by the async value. -extern "C" ValueStorage mlirAsyncRuntimeGetValueStorage(AsyncValue *value) -{ - assert(!State(value->state).isError() && "unexpected error state"); - return value->storage.data(); -} - -extern "C" void mlirAsyncRuntimeExecute(CoroHandle handle, CoroResume resume) -{ - auto *runtime = getDefaultAsyncRuntime(); - runtime->getThreadPool().async([handle, resume]() - { (*resume)(handle); }); -} - -extern "C" void mlirAsyncRuntimeAwaitTokenAndExecute(AsyncToken *token, CoroHandle handle, CoroResume resume) -{ - auto execute = [handle, resume]() - { (*resume)(handle); }; - std::unique_lock lock(token->mu); - if (State(token->state).isAvailableOrError()) - { - lock.unlock(); - execute(); - } - else - { - token->awaiters.emplace_back([execute]() - { execute(); }); - } -} - -extern "C" void mlirAsyncRuntimeAwaitValueAndExecute(AsyncValue *value, CoroHandle handle, CoroResume resume) -{ - auto execute = [handle, resume]() - { (*resume)(handle); }; - std::unique_lock lock(value->mu); - if (State(value->state).isAvailableOrError()) - { - lock.unlock(); - execute(); - } - else - { - value->awaiters.emplace_back([execute]() - { execute(); }); - } -} - -extern "C" void mlirAsyncRuntimeAwaitAllInGroupAndExecute(AsyncGroup *group, CoroHandle handle, CoroResume resume) -{ - auto execute = [handle, resume]() - { (*resume)(handle); }; - std::unique_lock lock(group->mu); - if (group->pendingTokens == 0) - { - lock.unlock(); - execute(); - } - else - { - group->awaiters.emplace_back([execute]() - { execute(); }); - } -} - -extern "C" int64_t mlirAsyncRuntimGetNumWorkerThreads() -{ - return getDefaultAsyncRuntime()->getThreadPool().getMaxConcurrency(); -} - -//===----------------------------------------------------------------------===// -// Small async runtime support library for testing. -//===----------------------------------------------------------------------===// - -extern "C" void mlirAsyncRuntimePrintCurrentThreadId() -{ - static thread_local std::thread::id thisId = std::this_thread::get_id(); - std::cout << "Current thread id: " << thisId << std::endl; -} - -} // namespace runtime -} // namespace mlir +#endif // _WIN32 #endif // MLIR_ASYNCRUNTIME_DEFINE_FUNCTIONS diff --git a/tslang/lib/TypeScriptAsyncRuntime/CMakeLists.txt b/tslang/lib/TypeScriptAsyncRuntime/CMakeLists.txt index a77036b6e..178ff104b 100644 --- a/tslang/lib/TypeScriptAsyncRuntime/CMakeLists.txt +++ b/tslang/lib/TypeScriptAsyncRuntime/CMakeLists.txt @@ -9,4 +9,9 @@ add_mlir_library(TypeScriptAsyncRuntime AsyncRuntime.cpp EXCLUDE_FROM_LIBMLIR + + # For gc.h only - AsyncGCThreads.h registers the pool's workers with the collector when the + # program is a `gc` one. The collector itself is linked into the executable, not into here. + LINK_LIBS PRIVATE + BDWgc::gc ) diff --git a/tslang/lib/TypeScriptExceptionPass/Win32ExceptionPass.cpp b/tslang/lib/TypeScriptExceptionPass/Win32ExceptionPass.cpp index 46cdbec9e..9588b698b 100644 --- a/tslang/lib/TypeScriptExceptionPass/Win32ExceptionPass.cpp +++ b/tslang/lib/TypeScriptExceptionPass/Win32ExceptionPass.cpp @@ -72,6 +72,13 @@ struct Win32ExceptionPassCode { } + static bool isEndCatchCall(llvm::Instruction *I) + { + auto *CI = dyn_cast(I); + return CI && CI->getCalledFunction() != nullptr && CI->getCalledFunction()->hasName() && + CI->getCalledFunction()->getName() == "__cxa_end_catch"; + } + bool runOnFunction(Function &F) { auto MadeChange = false; @@ -101,6 +108,15 @@ struct Win32ExceptionPassCode // it is outsize of catch/finally region if (!catchRegion) { + // A surplus end-of-catch marker, with no region left for it to close. See the + // note at the `endOfCatch` handling below for where they come from; either way + // one that survives is an unresolved symbol at link time, so it goes. + if (isEndCatchCall(&I)) + { + toRemoveWorkSet.push_back(&I); + MadeChange = true; + } + continue; } @@ -113,6 +129,24 @@ struct Win32ExceptionPassCode if (endOfCatch) { + // A second end-of-catch marker can follow the one that just closed this region, + // and it must not become the region's `end`: `end` is where the catchret goes, + // and leaving an `__cxa_end_catch` there keeps an Itanium marker with no Win64 + // counterpart in the emitted code - an unresolved symbol at link time. + // + // They arise wherever a catch clause is ended twice over. A `try/catch` written + // inside a catch clause is the case that found this: the inner `throw` ends the + // enclosing catch ahead of itself (§9.14), and the outer try then emits its own + // end marker as well, so the tail carries both. Skipping past them - staying in + // this state rather than leaving it - handles any number of them and leaves the + // real end instruction to close the region. + if (isEndCatchCall(&I)) + { + toRemoveWorkSet.push_back(&I); + MadeChange = true; + continue; + } + // BR, or instraction without BR catchRegion->end = &I; endOfCatch = false; @@ -169,8 +203,34 @@ struct Win32ExceptionPassCode // possible end if (CI->getCalledFunction()->getName() == "_CxxThrowException") { - // do not put continue, we need to add facelet catchRegion->end = &I; + + // For a catch, the end-of-catch handling below splits the block ahead + // of `end` and emits the catchret there, which leaves this call on the + // far side of it - outside the funclet. So it must not be collected + // into `calls`, which is what stamps the funclet bundle on: a bundle + // naming a pad the throw has already returned from is malformed IR, + // and it crashes the backend. + // + // The __cxa_end_catch marker above keeps the two apart for this same + // instruction whenever MLIRGen emitted one, by closing the region + // before the throw is ever reached. A throw the inliner brought in has + // no marker - the EndCatchOp that followed the call it replaced went + // with the rest of the now-unreachable code after it - so it has to be + // recognised on its own. + // + // Skipping the call is all that takes, though: leave the region open. + // This scan walks the function's instructions in order rather than by + // region, so after inlining a throw belonging to one catch can turn up + // while another is still open, and closing on it would strand the rest + // of that catch's calls with no bundle at all (00try_catch.ts). + // + // A cleanup region gets no catchret, so its throw stays inside the + // funclet and does still need the bundle; leave that path alone. + if (catchRegion->isCatch()) + { + continue; + } } else { @@ -646,6 +706,20 @@ struct Win32ExceptionPassCode InvokeInst *ToInvoke(CallBase *CB, BasicBlock *unwind, llvm::SmallVector &opBundle) { + // An invoke already ends its block and already names a normal destination, so all it + // needs is its unwind edge redirected and the bundle added - not a block of its own. + // Splitting at one puts it alone in the continuation block, and every caller erases it + // immediately afterwards, which leaves that block empty and without a terminator while + // the real continuation is left with no predecessors at all. A `using` in a nested + // scope inside a try body produced exactly that, and the empty block crashed the + // inliner. Cloning it in place is what the funclet-bundle loop above already does. + if (auto *II = dyn_cast(CB)) + { + auto *newInvoke = cast(CallBase::Create(II, opBundle, II->getIterator())); + newInvoke->setUnwindDest(unwind); + return newInvoke; + } + BasicBlock *CurrentBB = CB->getParent(); BasicBlock *ContinuationBB = CurrentBB->splitBasicBlock(CB->getIterator(), "invoke.cont"); diff --git a/tslang/lib/TypeScriptRuntime/AsyncRuntime.cpp b/tslang/lib/TypeScriptRuntime/AsyncRuntime.cpp index e6dda4014..ea0fe31ff 100644 --- a/tslang/lib/TypeScriptRuntime/AsyncRuntime.cpp +++ b/tslang/lib/TypeScriptRuntime/AsyncRuntime.cpp @@ -9,8 +9,10 @@ // //===----------------------------------------------------------------------===// // -// This file implements basic Async runtime API for supporting Async dialect -// to LLVM dialect lowering. +// Built into the shared TypeScriptRuntime library, loaded by the JIT via +// `--shared-libs`. The implementation itself lives in AsyncRuntimeCommon.inc, +// shared with TypeScriptAsyncRuntime (the static, AOT-linked build of the same +// API); this file adds the JIT's dynamic export table on top of it. // //===----------------------------------------------------------------------===// @@ -19,537 +21,7 @@ #ifdef MLIR_ASYNCRUNTIME_DEFINE_FUNCTIONS -#include -#include -#include -#include -#include -#include -#include -#include - -#include "llvm/ADT/StringMap.h" -#include "llvm/Support/ThreadPool.h" - -using namespace mlir::runtime; - -//===----------------------------------------------------------------------===// -// Async runtime API. -//===----------------------------------------------------------------------===// - -namespace mlir -{ -namespace runtime -{ -namespace -{ - - // Forward declare class defined below. - class RefCounted; - - // -------------------------------------------------------------------------- // - // AsyncRuntime orchestrates all async operations and Async runtime API is built - // on top of the default runtime instance. - // -------------------------------------------------------------------------- // - - class AsyncRuntime - { - public: - AsyncRuntime() : numRefCountedObjects(0) - { - } - - ~AsyncRuntime() - { - threadPool.wait(); // wait for the completion of all async tasks - assert(getNumRefCountedObjects() == 0 && "all ref counted objects must be destroyed"); - } - - int64_t getNumRefCountedObjects() - { - return numRefCountedObjects.load(std::memory_order_relaxed); - } - - llvm::ThreadPoolInterface &getThreadPool() - { - return threadPool; - } - - private: - friend class RefCounted; - - // Count the total number of reference counted objects in this instance - // of an AsyncRuntime. For debugging purposes only. - void addNumRefCountedObjects() - { - numRefCountedObjects.fetch_add(1, std::memory_order_relaxed); - } - void dropNumRefCountedObjects() - { - numRefCountedObjects.fetch_sub(1, std::memory_order_relaxed); - } - - std::atomic numRefCountedObjects; - llvm::DefaultThreadPool threadPool; - }; - - // -------------------------------------------------------------------------- // - // A state of the async runtime value (token, value or group). - // -------------------------------------------------------------------------- // - - class State - { - public: - enum StateEnum : int8_t - { - // The underlying value is not yet available for consumption. - kUnavailable = 0, - // The underlying value is available for consumption. This state can not - // transition to any other state. - kAvailable = 1, - // This underlying value is available and contains an error. This state can - // not transition to any other state. - kError = 2, - }; - - /* implicit */ State(StateEnum s) : state(s) - { - } - /* implicit */ operator StateEnum() - { - return state; - } - - bool isUnavailable() const - { - return state == kUnavailable; - } - bool isAvailable() const - { - return state == kAvailable; - } - bool isError() const - { - return state == kError; - } - bool isAvailableOrError() const - { - return isAvailable() || isError(); - } - - const char *debug() const - { - switch (state) - { - case kUnavailable: - return "unavailable"; - case kAvailable: - return "available"; - case kError: - return "error"; - } - } - - private: - StateEnum state; - }; - - // -------------------------------------------------------------------------- // - // A base class for all reference counted objects created by the async runtime. - // -------------------------------------------------------------------------- // - - class RefCounted - { - public: - RefCounted(AsyncRuntime *runtime, int64_t refCount = 1) : runtime(runtime), refCount(refCount) - { - runtime->addNumRefCountedObjects(); - } - - virtual ~RefCounted() - { - assert(refCount.load() == 0 && "reference count must be zero"); - runtime->dropNumRefCountedObjects(); - } - - RefCounted(const RefCounted &) = delete; - RefCounted &operator=(const RefCounted &) = delete; - - void addRef(int64_t count = 1) - { - refCount.fetch_add(count); - } - - void dropRef(int64_t count = 1) - { - int64_t previous = refCount.fetch_sub(count); - assert(previous >= count && "reference count should not go below zero"); - if (previous == count) - destroy(); - } - - protected: - virtual void destroy() - { - delete this; - } - - private: - AsyncRuntime *runtime; - std::atomic refCount; - }; - -} // namespace - -// Returns the default per-process instance of an async runtime. -static std::unique_ptr &getDefaultAsyncRuntimeInstance() -{ - static auto runtime = std::make_unique(); - return runtime; -} - -static void resetDefaultAsyncRuntime() -{ - return getDefaultAsyncRuntimeInstance().reset(); -} - -static AsyncRuntime *getDefaultAsyncRuntime() -{ - return getDefaultAsyncRuntimeInstance().get(); -} - -// Async token provides a mechanism to signal asynchronous operation completion. -struct AsyncToken : public RefCounted -{ - // AsyncToken created with a reference count of 2 because it will be returned - // to the `async.execute` caller and also will be later on emplaced by the - // asynchronously executed task. If the caller immediately will drop its - // reference we must ensure that the token will be alive until the - // asynchronous operation is completed. - AsyncToken(AsyncRuntime *runtime) : RefCounted(runtime, /*refCount=*/2), state(State::kUnavailable) - { - } - - std::atomic state; - - // Pending awaiters are guarded by a mutex. - std::mutex mu; - std::condition_variable cv; - std::vector> awaiters; -}; - -// Async value provides a mechanism to access the result of asynchronous -// operations. It owns the storage that is used to store/load the value of the -// underlying type, and a flag to signal if the value is ready or not. -struct AsyncValue : public RefCounted -{ - // AsyncValue similar to an AsyncToken created with a reference count of 2. - AsyncValue(AsyncRuntime *runtime, int64_t size) - : RefCounted(runtime, /*refCount=*/2), state(State::kUnavailable), storage(size) - { - } - - std::atomic state; - - // Use vector of bytes to store async value payload. - std::vector storage; - - // Pending awaiters are guarded by a mutex. - std::mutex mu; - std::condition_variable cv; - std::vector> awaiters; -}; - -// Async group provides a mechanism to group together multiple async tokens or -// values to await on all of them together (wait for the completion of all -// tokens or values added to the group). -struct AsyncGroup : public RefCounted -{ - AsyncGroup(AsyncRuntime *runtime, int64_t size) : RefCounted(runtime), pendingTokens(size), numErrors(0), rank(0) - { - } - - std::atomic pendingTokens; - std::atomic numErrors; - std::atomic rank; - - // Pending awaiters are guarded by a mutex. - std::mutex mu; - std::condition_variable cv; - std::vector> awaiters; -}; - -// Adds references to reference counted runtime object. -extern "C" void mlirAsyncRuntimeAddRef(RefCountedObjPtr ptr, int64_t count) -{ - RefCounted *refCounted = static_cast(ptr); - refCounted->addRef(count); -} - -// Drops references from reference counted runtime object. -extern "C" void mlirAsyncRuntimeDropRef(RefCountedObjPtr ptr, int64_t count) -{ - RefCounted *refCounted = static_cast(ptr); - refCounted->dropRef(count); -} - -// Creates a new `async.token` in not-ready state. -extern "C" AsyncToken *mlirAsyncRuntimeCreateToken() -{ - AsyncToken *token = new AsyncToken(getDefaultAsyncRuntime()); - return token; -} - -// Creates a new `async.value` in not-ready state. -extern "C" AsyncValue *mlirAsyncRuntimeCreateValue(int64_t size) -{ - AsyncValue *value = new AsyncValue(getDefaultAsyncRuntime(), size); - return value; -} - -// Create a new `async.group` in empty state. -extern "C" AsyncGroup *mlirAsyncRuntimeCreateGroup(int64_t size) -{ - AsyncGroup *group = new AsyncGroup(getDefaultAsyncRuntime(), size); - return group; -} - -extern "C" int64_t mlirAsyncRuntimeAddTokenToGroup(AsyncToken *token, AsyncGroup *group) -{ - std::unique_lock lockToken(token->mu); - std::unique_lock lockGroup(group->mu); - - // Get the rank of the token inside the group before we drop the reference. - int rank = group->rank.fetch_add(1); - - // HACK: ASD: to support dynamic size - group->pendingTokens.fetch_add(1); - - auto onTokenReady = [group, token]() - { - // Increment the number of errors in the group. - if (State(token->state).isError()) - group->numErrors.fetch_add(1); - - // If pending tokens go below zero it means that more tokens than the group - // size were added to this group. - assert(group->pendingTokens > 0 && "wrong group size"); - - // Run all group awaiters if it was the last token in the group. - if (group->pendingTokens.fetch_sub(1) == 1) - { - group->cv.notify_all(); - for (auto &awaiter : group->awaiters) - awaiter(); - } - }; - - if (State(token->state).isAvailableOrError()) - { - // Update group pending tokens immediately and maybe run awaiters. - onTokenReady(); - } - else - { - // Update group pending tokens when token will become ready. Because this - // will happen asynchronously we must ensure that `group` is alive until - // then, and re-ackquire the lock. - group->addRef(); - - token->awaiters.emplace_back([group, onTokenReady]() - { - // Make sure that `dropRef` does not destroy the mutex owned by the lock. - { - std::unique_lock lockGroup(group->mu); - onTokenReady(); - } - group->dropRef(); }); - } - - return rank; -} - -// Switches `async.token` to available or error state (terminatl state) and runs -// all awaiters. -static void setTokenState(AsyncToken *token, State state) -{ - assert(state.isAvailableOrError() && "must be terminal state"); - assert(State(token->state).isUnavailable() && "token must be unavailable"); - - // Make sure that `dropRef` does not destroy the mutex owned by the lock. - { - std::unique_lock lock(token->mu); - token->state = state; - token->cv.notify_all(); - for (auto &awaiter : token->awaiters) - awaiter(); - } - - // Async tokens created with a ref count `2` to keep token alive until the - // async task completes. Drop this reference explicitly when token emplaced. - token->dropRef(); -} - -static void setValueState(AsyncValue *value, State state) -{ - assert(state.isAvailableOrError() && "must be terminal state"); - assert(State(value->state).isUnavailable() && "value must be unavailable"); - - // Make sure that `dropRef` does not destroy the mutex owned by the lock. - { - std::unique_lock lock(value->mu); - value->state = state; - value->cv.notify_all(); - for (auto &awaiter : value->awaiters) - awaiter(); - } - - // Async values created with a ref count `2` to keep value alive until the - // async task completes. Drop this reference explicitly when value emplaced. - value->dropRef(); -} - -extern "C" void mlirAsyncRuntimeEmplaceToken(AsyncToken *token) -{ - setTokenState(token, State::kAvailable); -} - -extern "C" void mlirAsyncRuntimeEmplaceValue(AsyncValue *value) -{ - setValueState(value, State::kAvailable); -} - -extern "C" void mlirAsyncRuntimeSetTokenError(AsyncToken *token) -{ - setTokenState(token, State::kError); -} - -extern "C" void mlirAsyncRuntimeSetValueError(AsyncValue *value) -{ - setValueState(value, State::kError); -} - -extern "C" bool mlirAsyncRuntimeIsTokenError(AsyncToken *token) -{ - return State(token->state).isError(); -} - -extern "C" bool mlirAsyncRuntimeIsValueError(AsyncValue *value) -{ - return State(value->state).isError(); -} - -extern "C" bool mlirAsyncRuntimeIsGroupError(AsyncGroup *group) -{ - return group->numErrors.load() > 0; -} - -extern "C" void mlirAsyncRuntimeAwaitToken(AsyncToken *token) -{ - std::unique_lock lock(token->mu); - if (!State(token->state).isAvailableOrError()) - token->cv.wait(lock, [token] - { return State(token->state).isAvailableOrError(); }); -} - -extern "C" void mlirAsyncRuntimeAwaitValue(AsyncValue *value) -{ - std::unique_lock lock(value->mu); - if (!State(value->state).isAvailableOrError()) - value->cv.wait(lock, [value] - { return State(value->state).isAvailableOrError(); }); -} - -extern "C" void mlirAsyncRuntimeAwaitAllInGroup(AsyncGroup *group) -{ - std::unique_lock lock(group->mu); - if (group->pendingTokens != 0) - group->cv.wait(lock, [group] - { return group->pendingTokens == 0; }); -} - -// Returns a pointer to the storage owned by the async value. -extern "C" ValueStorage mlirAsyncRuntimeGetValueStorage(AsyncValue *value) -{ - assert(!State(value->state).isError() && "unexpected error state"); - return value->storage.data(); -} - -extern "C" void mlirAsyncRuntimeExecute(CoroHandle handle, CoroResume resume) -{ - auto *runtime = getDefaultAsyncRuntime(); - runtime->getThreadPool().async([handle, resume]() - { (*resume)(handle); }); -} - -extern "C" void mlirAsyncRuntimeAwaitTokenAndExecute(AsyncToken *token, CoroHandle handle, CoroResume resume) -{ - auto execute = [handle, resume]() - { (*resume)(handle); }; - std::unique_lock lock(token->mu); - if (State(token->state).isAvailableOrError()) - { - lock.unlock(); - execute(); - } - else - { - token->awaiters.emplace_back([execute]() - { execute(); }); - } -} - -extern "C" void mlirAsyncRuntimeAwaitValueAndExecute(AsyncValue *value, CoroHandle handle, CoroResume resume) -{ - auto execute = [handle, resume]() - { (*resume)(handle); }; - std::unique_lock lock(value->mu); - if (State(value->state).isAvailableOrError()) - { - lock.unlock(); - execute(); - } - else - { - value->awaiters.emplace_back([execute]() - { execute(); }); - } -} - -extern "C" void mlirAsyncRuntimeAwaitAllInGroupAndExecute(AsyncGroup *group, CoroHandle handle, CoroResume resume) -{ - auto execute = [handle, resume]() - { (*resume)(handle); }; - std::unique_lock lock(group->mu); - if (group->pendingTokens == 0) - { - lock.unlock(); - execute(); - } - else - { - group->awaiters.emplace_back([execute]() - { execute(); }); - } -} - -extern "C" int64_t mlirAsyncRuntimGetNumWorkerThreads() -{ - return getDefaultAsyncRuntime()->getThreadPool().getMaxConcurrency(); -} - -//===----------------------------------------------------------------------===// -// Small async runtime support library for testing. -//===----------------------------------------------------------------------===// - -extern "C" void mlirAsyncRuntimePrintCurrentThreadId() -{ - static thread_local std::thread::id thisId = std::this_thread::get_id(); - std::cout << "Current thread id: " << thisId << std::endl; -} - -} // namespace runtime -} // namespace mlir +#include "../AsyncRuntimeCommon.inc" //===----------------------------------------------------------------------===// // MLIR Runner (JitRunner) dynamic library integration. diff --git a/tslang/lib/TypeScriptRuntime/MemRuntime.cpp b/tslang/lib/TypeScriptRuntime/MemRuntime.cpp index fc97eb53c..c7543b977 100644 --- a/tslang/lib/TypeScriptRuntime/MemRuntime.cpp +++ b/tslang/lib/TypeScriptRuntime/MemRuntime.cpp @@ -1,4 +1,10 @@ -// TODO: somehow when we use align_alloc & align_free, I can see the error: pointer is broken +// The coroutine lowering asks for a frame with `aligned_alloc` and gives it back with plain +// `free`, which is the C11 pairing and is fine everywhere `aligned_alloc` exists. MSVC has no +// `aligned_alloc`, and `_aligned_malloc` - the obvious stand-in - hands back memory that only +// `_aligned_free` may release, so that pairing corrupts the CRT heap. Windows' own `malloc` is +// already aligned enough for everything that asks, so the request is served from the ordinary +// heap and `free` stays honest. (Under `-mm=gc` this never showed, because GCPass rewrites the +// whole pair to GC_memalign/GC_free.) #ifndef _WIN32 #if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) @@ -12,6 +18,7 @@ #endif // _WIN32 #include +#include #include #include "llvm/ADT/StringMap.h" @@ -27,9 +34,23 @@ namespace runtime extern "C" void *Alloc(uint64_t size) { return malloc(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 - return _aligned_malloc(size, alignment); + // Everything here comes from `malloc`, 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 + // worse of the two failures, so say so. + if (alignment > kMallocAlignment) + { + fprintf(stderr, "tslang runtime: alignment of %" PRIu64 " requested, only %" PRIu64 " is available\n", + alignment, kMallocAlignment); + } + + return malloc(size); #else void *result = nullptr; (void)::posix_memalign(&result, alignment, size); @@ -39,13 +60,7 @@ extern "C" void *AlignedAlloc(uint64_t alignment, uint64_t size) { extern "C" void Free(void *ptr) { free(ptr); } -extern "C" void AlignedFree(void *ptr) { -#ifdef _WIN32 - _aligned_free(ptr); -#else - free(ptr); -#endif -} +extern "C" void AlignedFree(void *ptr) { free(ptr); } } // namespace runtime } // namespace mlir diff --git a/tslang/lib/TypeScriptRuntime/TypeScriptRuntime.def b/tslang/lib/TypeScriptRuntime/TypeScriptRuntime.def index ac1005e20..572b8a425 100644 --- a/tslang/lib/TypeScriptRuntime/TypeScriptRuntime.def +++ b/tslang/lib/TypeScriptRuntime/TypeScriptRuntime.def @@ -23,6 +23,10 @@ EXPORTS GC_unregister_disappearing_link=_mlir__GC_unregister_disappearing_link GC_gcollect=_mlir__GC_gcollect + ; --- tells the collector it is multi-threaded and lets the async runtime's pool workers + ; --- register themselves (AsyncRuntime.cpp / AsyncGCThreads.h); injected beside GC_init + GC_enable_threads + ; --- memory runtime (MemRuntime.cpp, extern "C") --- _mlir_alloc=Alloc _mlir_aligned_alloc=AlignedAlloc diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 794edb9e2..a8e4abaeb 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -78,7 +78,21 @@ target_link_libraries(test-runner PRIVATE ${LIBS}) string(TOLOWER "${CMAKE_BUILD_TYPE}" CMAKE_BUILD_TYPE_LOWERCASE) # GC lib dir derived from the BDWgc package (BDWgc_DIR=/lib/cmake/bdwgc) get_filename_component(TEST_GC_LIBDIR "${BDWgc_DIR}/../.." ABSOLUTE) +# A shared Boehm, for the shared-library tests only. Two binaries that each link gc.lib +# statically get two collectors, and the one inside the library then frees objects the +# executable is still holding - item 5ao. Built by scripts/build_gc_release_shared_vs.bat; +# when it is absent the shared-library tests are skipped rather than run wrong. +set(TEST_GC_SHARED_PREFIX "${PROJECT_SOURCE_DIR}/../3rdParty/gcdll/x64/${CMAKE_BUILD_TYPE_LOWERCASE}") +if (EXISTS "${TEST_GC_SHARED_PREFIX}/lib/gc.lib" OR EXISTS "${TEST_GC_SHARED_PREFIX}/lib/libgc.so") + set(TSLANG_HAVE_SHARED_GC TRUE) +else() + set(TSLANG_HAVE_SHARED_GC FALSE) + message(STATUS "shared Boehm not found at ${TEST_GC_SHARED_PREFIX} - shared-library tests will be skipped (see scripts/build_gc_release_shared_vs.bat)") +endif() + if (WIN32) + target_compile_definitions(test-runner PUBLIC "TEST_GC_SHARED_LIBPATH=\"${TEST_GC_SHARED_PREFIX}/lib\"") + target_compile_definitions(test-runner PUBLIC "TEST_GC_SHARED_BINPATH=\"${TEST_GC_SHARED_PREFIX}/bin\"") target_compile_definitions(test-runner PUBLIC "TEST_LIBPATH=\"${VC_LIB_DIR}\"") target_compile_definitions(test-runner PUBLIC "TEST_SDKPATH=\"${WINDOWS_KITS_DIR_LIB}\"") target_compile_definitions(test-runner PUBLIC "TEST_UCRTPATH=\"${WINDOWS_KITS_DIR_UCRT_LIB}\"") @@ -89,6 +103,8 @@ if (WIN32) target_compile_definitions(test-runner PUBLIC "TEST_LLVM_EXEPATH=\"${PROJECT_SOURCE_DIR}/../3rdParty/llvm/x64/${CMAKE_BUILD_TYPE_LOWERCASE}/bin\"") target_compile_definitions(test-runner PUBLIC "TEST_LLVM_LIBPATH=\"${PROJECT_SOURCE_DIR}/../3rdParty/llvm/x64/${CMAKE_BUILD_TYPE_LOWERCASE}/lib\"") else() + target_compile_definitions(test-runner PUBLIC "TEST_GC_SHARED_LIBPATH=\"${TEST_GC_SHARED_PREFIX}/lib\"") + target_compile_definitions(test-runner PUBLIC "TEST_GC_SHARED_BINPATH=\"${TEST_GC_SHARED_PREFIX}/lib\"") target_compile_definitions(test-runner PUBLIC "TEST_GCPATH=\"${TEST_GC_LIBDIR}\"") target_compile_definitions(test-runner PUBLIC "TEST_TSLANG_EXEPATH=\"${CMAKE_BINARY_DIR}/bin\"") target_compile_definitions(test-runner PUBLIC "TEST_TSLANG_LIBPATH=\"${CMAKE_BINARY_DIR}/lib\"") @@ -109,7 +125,15 @@ file(REMOVE "${CMAKE_CURRENT_BINARY_DIR}/compilefm.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitfm.bat" "${CMAKE_CURRENT_BINARY_DIR}/compiledfm.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitdfm.bat" "${CMAKE_CURRENT_BINARY_DIR}/compilefm.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitfm.sh" - "${CMAKE_CURRENT_BINARY_DIR}/compiledfm.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdfm.sh") + "${CMAKE_CURRENT_BINARY_DIR}/compiledfm.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdfm.sh" + "${CMAKE_CURRENT_BINARY_DIR}/compilerc.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitrc.bat" + "${CMAKE_CURRENT_BINARY_DIR}/compiledrc.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitdrc.bat" + "${CMAKE_CURRENT_BINARY_DIR}/compilerc.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitrc.sh" + "${CMAKE_CURRENT_BINARY_DIR}/compiledrc.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdrc.sh" + "${CMAKE_CURRENT_BINARY_DIR}/compilenone.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitnone.bat" + "${CMAKE_CURRENT_BINARY_DIR}/compilednone.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitdnone.bat" + "${CMAKE_CURRENT_BINARY_DIR}/compilenone.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitnone.sh" + "${CMAKE_CURRENT_BINARY_DIR}/compilednone.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdnone.sh") ######## enable testing ############ # open __build\tslang @@ -162,6 +186,7 @@ add_test(NAME test-compile-00-break-continue COMMAND test-runner "${PROJECT_SOUR add_test(NAME test-compile-00-vars COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00vars.ts") add_test(NAME test-compile-00-var-bindings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00var_bindings.ts") add_test(NAME test-compile-00-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals.ts") +add_test(NAME test-compile-00-globals-entry-point COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals_entry_point.ts") add_test(NAME test-compile-00-globals2 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals2.ts") add_test(NAME test-compile-00-globals3 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals3.ts") add_test(NAME test-compile-00-arrays COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array.ts") @@ -203,6 +228,7 @@ add_test(NAME test-compile-00-union-type COMMAND test-runner "${PROJECT_SOURCE_D add_test(NAME test-compile-01-union-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01union_type.ts") add_test(NAME test-compile-02-union-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/02union_type.ts") add_test(NAME test-compile-03-union-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/03union_type.ts") +add_test(NAME test-compile-03-union-type-case-order COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/03union_type_case_order.ts") add_test(NAME test-compile-04-union-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/04union_type.ts") add_test(NAME test-compile-05-union-type COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/05union_type.ts") add_test(NAME test-compile-00-union-ops COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_ops.ts") @@ -221,6 +247,38 @@ add_test(NAME test-compile-00-sizeof COMMAND test-runner "${PROJECT_SOURCE_DIR}/ add_test(NAME test-compile-01-sizeof COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01sizeof.ts") add_test(NAME test-compile-02-sizeof COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/02sizeof.ts") add_test(NAME test-compile-00-new-delete COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00new_delete.ts") +add_test(NAME test-compile-00-owned-locals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_locals.ts") +# The JIT variants are the ones that matter here: this shape was a JIT-only miscompile until +# the catchable-type size was fixed (see the file header). Kept in all three models, since the +# bad size was model-independent. +add_test(NAME test-compile-00-try-using-catch COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_using_catch.ts") +add_test(NAME test-compile-00-throw-in-catch COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_in_catch.ts") +add_test(NAME test-compile-00-throw-inlined COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_inlined.ts") +add_test(NAME test-compile-00-using-nested-scopes COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_nested_scopes.ts") +add_test(NAME test-compile-00-using-unwind-progress COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_unwind_progress.ts") +add_test(NAME test-compile-00-break-continue-scope-exit COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00break_continue_scope_exit.ts") +add_test(NAME test-compile-00-owned-fields COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_fields.ts") +add_test(NAME test-compile-00-owned-elements COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_elements.ts") +add_test(NAME test-compile-00-owned-literals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_literals.ts") +add_test(NAME test-compile-00-owned-array-ops COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_array_ops.ts") +add_test(NAME test-compile-00-owned-inline-records COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_inline_records.ts") +add_test(NAME test-compile-00-owned-transfer COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_transfer.ts") +add_test(NAME test-compile-00-owned-call-results COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_call_results.ts") +add_test(NAME test-compile-00-owned-temporaries COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_temporaries.ts") +add_test(NAME test-compile-00-owned-interfaces COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_interfaces.ts") +add_test(NAME test-compile-00-owned-closures COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_closures.ts") +add_test(NAME test-compile-00-owned-any-boxing COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") +add_test(NAME test-compile-00-owned-strings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") +add_test(NAME test-compile-00-owned-generators COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") +add_test(NAME test-compile-00-owned-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") +add_test(NAME test-compile-00-owned-generator-locals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") +add_test(NAME test-compile-00-owned-iteration COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_iteration.ts") +add_test(NAME test-compile-00-owned-async COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_async.ts") +add_test(NAME test-compile-00-owned-nested-captures COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_nested_captures.ts") +add_test(NAME test-compile-00-owned-unions COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_unions.ts") +add_test(NAME test-compile-00-owned-delete COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_delete.ts") +add_test(NAME test-compile-00-owned-early-return COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_early_return.ts") +add_test(NAME test-compile-00-owned-construct-interface COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_construct_interface.ts") add_test(NAME test-compile-00-void COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00void.ts") add_test(NAME test-compile-00-in COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00in.ts") add_test(NAME test-compile-00-in-method-names COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00in_method_names.ts") @@ -228,6 +286,7 @@ add_test(NAME test-compile-00-class-structural-extends COMMAND test-runner "${PR add_test(NAME test-compile-00-instanceof COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00instanceof.ts") add_test(NAME test-compile-00-class COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class.ts") add_test(NAME test-compile-00-class-new COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_new.ts") +add_test(NAME test-compile-00-capture-in-new-arguments COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00capture_in_new_arguments.ts") add_test(NAME test-compile-01-class-new COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01class_new.ts") add_test(NAME test-compile-00-class-stack COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_stack.ts") add_test(NAME test-compile-00-class-static COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_static.ts") @@ -325,11 +384,15 @@ add_test(NAME test-compile-00-safe-cast-bug COMMAND test-runner "${PROJECT_SOURC add_test(NAME test-compile-00-optional COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional.ts") add_test(NAME test-compile-01-optional COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01optional.ts") add_test(NAME test-compile-00-async-await COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00async_await.ts") +add_test(NAME test-compile-00-async-result-types COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00async_result_types.ts") +add_test(NAME test-compile-00-async-gc-threading COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00async_gc_threading.ts") add_test(NAME test-compile-00-for-await COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00for_await.ts") add_test(NAME test-compile-00-for-await-yield COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00for_await_yield.ts") add_test(NAME test-compile-00-types COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00types.ts") add_test(NAME test-compile-00-try-catch COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_catch.ts") +add_test(NAME test-compile-00-catch-value COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00catch_value.ts") +add_test(NAME test-compile-00-catch-value-minimal COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00catch_value_minimal.ts") add_test(NAME test-compile-01-try-catch COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01try_catch.ts") add_test(NAME test-compile-00-try-catch-return COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_catch_return.ts") add_test(NAME test-compile-00-try-finally COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_finally.ts") @@ -388,6 +451,8 @@ add_test(NAME test-compile-00-conditional-type COMMAND test-runner "${PROJECT_SO add_test(NAME test-compile-00-disposable COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00disposable.ts") add_test(NAME test-compile-01-disposable COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01disposable.ts") add_test(NAME test-compile-02-disposable COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/02disposable.ts") +add_test(NAME test-compile-03-disposable COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/03disposable.ts") +add_test(NAME test-compile-04-disposable COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/04disposable.ts") add_test(NAME test-compile-00-if-conditional-compile COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00if_conditional_compile.ts") add_test(NAME test-compile-01-arguments COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01arguments.ts") add_test(NAME test-compile-02-numbers COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/02numbers.ts") @@ -535,6 +600,7 @@ add_test(NAME test-jit-00-break-continue COMMAND test-runner -jit "${PROJECT_SOU add_test(NAME test-jit-00-vars COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00vars.ts") add_test(NAME test-jit-00-var-bindings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00var_bindings.ts") add_test(NAME test-jit-00-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals.ts") +add_test(NAME test-jit-00-globals-entry-point COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals_entry_point.ts") add_test(NAME test-jit-00-globals2 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals2.ts") add_test(NAME test-jit-00-globals3 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals3.ts") add_test(NAME test-jit-00-arrays COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array.ts") @@ -576,6 +642,7 @@ add_test(NAME test-jit-00-union-type COMMAND test-runner -jit "${PROJECT_SOURCE_ add_test(NAME test-jit-01-union-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01union_type.ts") add_test(NAME test-jit-02-union-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/02union_type.ts") add_test(NAME test-jit-03-union-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/03union_type.ts") +add_test(NAME test-jit-03-union-type-case-order COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/03union_type_case_order.ts") add_test(NAME test-jit-04-union-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/04union_type.ts") add_test(NAME test-jit-05-union-type COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/05union_type.ts") add_test(NAME test-jit-00-union-ops COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_ops.ts") @@ -594,6 +661,35 @@ add_test(NAME test-jit-00-sizeof COMMAND test-runner -jit "${PROJECT_SOURCE_DIR} add_test(NAME test-jit-01-sizeof COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01sizeof.ts") add_test(NAME test-jit-02-sizeof COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/02sizeof.ts") add_test(NAME test-jit-00-new-delete COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00new_delete.ts") +add_test(NAME test-jit-00-owned-locals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_locals.ts") +add_test(NAME test-jit-00-throw-in-catch COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_in_catch.ts") +add_test(NAME test-jit-00-throw-inlined COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_inlined.ts") +add_test(NAME test-jit-00-using-nested-scopes COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_nested_scopes.ts") +add_test(NAME test-jit-00-using-unwind-progress COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_unwind_progress.ts") +add_test(NAME test-jit-00-break-continue-scope-exit COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00break_continue_scope_exit.ts") +add_test(NAME test-jit-00-owned-fields COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_fields.ts") +add_test(NAME test-jit-00-owned-elements COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_elements.ts") +add_test(NAME test-jit-00-owned-literals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_literals.ts") +add_test(NAME test-jit-00-owned-array-ops COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_array_ops.ts") +add_test(NAME test-jit-00-owned-inline-records COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_inline_records.ts") +add_test(NAME test-jit-00-owned-transfer COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_transfer.ts") +add_test(NAME test-jit-00-owned-call-results COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_call_results.ts") +add_test(NAME test-jit-00-owned-temporaries COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_temporaries.ts") +add_test(NAME test-jit-00-owned-interfaces COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_interfaces.ts") +add_test(NAME test-jit-00-owned-closures COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_closures.ts") +add_test(NAME test-jit-00-owned-any-boxing COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") +add_test(NAME test-jit-00-owned-strings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") +add_test(NAME test-jit-00-owned-generators COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") +add_test(NAME test-jit-00-owned-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") +add_test(NAME test-jit-00-owned-generator-locals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") +add_test(NAME test-jit-00-owned-iteration COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_iteration.ts") +add_test(NAME test-jit-00-owned-async COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_async.ts") +add_test(NAME test-jit-00-owned-nested-captures COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_nested_captures.ts") +add_test(NAME test-jit-00-owned-unions COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_unions.ts") +add_test(NAME test-jit-00-owned-delete COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_delete.ts") +add_test(NAME test-jit-00-owned-early-return COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_early_return.ts") +add_test(NAME test-jit-00-owned-construct-interface COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_construct_interface.ts") +add_test(NAME test-jit-00-try-using-catch COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_using_catch.ts") add_test(NAME test-jit-00-void COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00void.ts") add_test(NAME test-jit-00-in COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00in.ts") add_test(NAME test-jit-00-in-method-names COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00in_method_names.ts") @@ -601,6 +697,7 @@ add_test(NAME test-jit-00-class-structural-extends COMMAND test-runner -jit "${P add_test(NAME test-jit-00-instanceof COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00instanceof.ts") add_test(NAME test-jit-00-class COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class.ts") add_test(NAME test-jit-00-class-new COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_new.ts") +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") add_test(NAME test-jit-01-class-new COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01class_new.ts") add_test(NAME test-jit-00-class-stack COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_stack.ts") add_test(NAME test-jit-00-class-static COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_static.ts") @@ -698,9 +795,13 @@ add_test(NAME test-jit-00-safe-cast-bug COMMAND test-runner -jit "${PROJECT_SOUR add_test(NAME test-jit-00-optional COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00optional.ts") add_test(NAME test-jit-01-optional COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01optional.ts") add_test(NAME test-jit-00-async-await COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00async_await.ts") +add_test(NAME test-jit-00-async-result-types COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00async_result_types.ts") +add_test(NAME test-jit-00-async-gc-threading COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00async_gc_threading.ts") add_test(NAME test-jit-00-for-await COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00for_await.ts") add_test(NAME test-jit-00-for-await-yield COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00for_await_yield.ts") add_test(NAME test-jit-00-try-catch COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_catch.ts") +add_test(NAME test-jit-00-catch-value COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00catch_value.ts") +add_test(NAME test-jit-00-catch-value-minimal COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00catch_value_minimal.ts") add_test(NAME test-jit-01-try-catch COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01try_catch.ts") add_test(NAME test-jit-00-try-catch-return COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_catch_return.ts") add_test(NAME test-jit-00-try-finally COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_finally.ts") @@ -766,6 +867,8 @@ add_test(NAME test-jit-00-conditional-type COMMAND test-runner -jit "${PROJECT_S add_test(NAME test-jit-00-disposable COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00disposable.ts") add_test(NAME test-jit-01-disposable COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01disposable.ts") add_test(NAME test-jit-02-disposable COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/02disposable.ts") +add_test(NAME test-jit-03-disposable COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/03disposable.ts") +add_test(NAME test-jit-04-disposable COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/04disposable.ts") add_test(NAME test-jit-00-if-conditional-compile COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00if_conditional_compile.ts") add_test(NAME test-jit-01-arguments COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01arguments.ts") add_test(NAME test-jit-02-numbers COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/02numbers.ts") @@ -862,7 +965,18 @@ add_test(NAME test-compile-include-global-var COMMAND test-runner "${PROJECT_SOU # imports support only compile mode add_test(NAME test-compile-import-component COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/component.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/service.ts") +# A library whose root only declares and initializes a variable must not get an entry point of +# its own: it is compiled with the same --emit=obj as the program linked beside it, and a `main` +# in both fails to link. Only the program half is given --entry-point. +add_test(NAME test-compile-export-import-root-var COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_root_var.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_root_var.ts") add_test(NAME test-compile-export-import-class-interface COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +# Item 5al: an imported function's returned reference is taken over rather than retained +# again. Registered statically as well as shared, and under every memory model - the +# static two-module form had no rc/none coverage at all, which is why the leak it fixes +# went unmeasured for so long. +add_test(NAME test-compile-export-import-owned-returns COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") +add_test(NAME test-compile-rc-export-import-owned-returns COMMAND test-runner -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") +add_test(NAME test-compile-none-export-import-owned-returns COMMAND test-runner -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") add_test(NAME test-compile-export-import-class-extends COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") add_test(NAME test-compile-export-import-class-extends-multilevel COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") add_test(NAME test-compile-export-import-class-extends-implements-diamond COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") @@ -938,6 +1052,7 @@ add_test(NAME test-compile-shared-export-import-object-literal-with-class-types # DeclarationPrinter no longer prints a wrong extends target (own name instead of # the base's) nor the synthetic base-class storage field (which shifted every # subsequent field's offset in the importer). +add_test(NAME test-compile-shared-export-import-owned-returns COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") add_test(NAME test-compile-shared-export-import-class-extends COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") add_test(NAME test-compile-shared-export-import-class-extends-implements-diamond COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") add_test(NAME test-compile-shared-export-import-class-extends-multilevel COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") @@ -1018,6 +1133,7 @@ add_test(NAME test-jit-shared-decl-emit-class COMMAND test-runner -jit -shared " # shared libs tests (exports/imports) add_test(NAME test-jit-shared-export-import-class-interface COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") add_test(NAME test-jit-shared-export-import-object-literal-with-class-types COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_class_types.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_class_types.ts") +add_test(NAME test-jit-shared-export-import-owned-returns COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") add_test(NAME test-jit-shared-export-import-class-extends COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") add_test(NAME test-jit-shared-export-import-class-extends-multilevel COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") add_test(NAME test-jit-shared-export-import-class-extends-implements-diamond COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") @@ -1055,3 +1171,906 @@ add_test(NAME test-jit-shared-export-import-object-literal-structural-typed-exte add_test(NAME test-jit-shared-export-import-vars COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars.ts") add_test(NAME test-jit-shared-export-import-vars-2 COMMAND test-runner -jit -shared -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars2.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars2.ts") add_test(NAME test-jit-shared-export-import-enum COMMAND test-runner -jit -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_enum.ts") + +# -mm=rc builds every allocation with a live reference count in the block header, generates +# the reference-counting routines, and now calls them: a local holding a heap reference takes +# one at its declaration and gives it back at every exit from its scope. A representative set +# rather than the whole suite, since the cost is a second full compile per test. +# +# These are the only tests ownership insertion can break. The ops it emits erase whole under a +# collector, so the rest of the suite is immune to where they land, not merely expected to +# survive it. +add_test(NAME test-jit-rc-strings COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00strings.ts") +add_test(NAME test-jit-rc-str-null COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00str_null.ts") +add_test(NAME test-jit-rc-array COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00array.ts") +add_test(NAME test-jit-rc-array-push-pop COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00array4_push_pop.ts") +add_test(NAME test-jit-rc-array-splice COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_splice.ts") +add_test(NAME test-jit-rc-any COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00any.ts") +add_test(NAME test-jit-rc-any-compare COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00any_compare.ts") +add_test(NAME test-jit-rc-any-types COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00any_types.ts") +add_test(NAME test-jit-rc-class COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00class.ts") +add_test(NAME test-jit-rc-union-type COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00union_type.ts") +add_test(NAME test-jit-rc-tuple COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00tuple.ts") +add_test(NAME test-jit-rc-interface COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface.ts") +add_test(NAME test-jit-rc-generator COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00generator.ts") +add_test(NAME test-jit-rc-new-delete COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00new_delete.ts") +add_test(NAME test-jit-rc-try-catch COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_catch.ts") +add_test(NAME test-jit-rc-for-of COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00for_of.ts") +add_test(NAME test-jit-rc-print COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00print.ts") +add_test(NAME test-jit-rc-owned-locals COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_locals.ts") +add_test(NAME test-jit-rc-throw-in-catch COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_in_catch.ts") +add_test(NAME test-jit-rc-throw-inlined COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_inlined.ts") +add_test(NAME test-jit-none-throw-inlined COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00throw_inlined.ts") +add_test(NAME test-jit-rc-using-nested-scopes COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_nested_scopes.ts") +add_test(NAME test-jit-none-using-nested-scopes COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_nested_scopes.ts") +add_test(NAME test-jit-rc-using-unwind-progress COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_unwind_progress.ts") +add_test(NAME test-jit-none-using-unwind-progress COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_unwind_progress.ts") +add_test(NAME test-jit-rc-break-continue-scope-exit COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00break_continue_scope_exit.ts") +add_test(NAME test-jit-none-break-continue-scope-exit COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00break_continue_scope_exit.ts") +add_test(NAME test-jit-rc-owned-fields COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_fields.ts") +add_test(NAME test-jit-none-owned-fields COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_fields.ts") +add_test(NAME test-jit-rc-owned-elements COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_elements.ts") +add_test(NAME test-jit-none-owned-elements COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_elements.ts") +add_test(NAME test-jit-rc-owned-literals COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_literals.ts") +add_test(NAME test-jit-none-owned-literals COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_literals.ts") +add_test(NAME test-jit-rc-owned-array-ops COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_array_ops.ts") +add_test(NAME test-jit-none-owned-array-ops COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_array_ops.ts") +add_test(NAME test-jit-rc-owned-inline-records COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_inline_records.ts") +add_test(NAME test-jit-none-owned-inline-records COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_inline_records.ts") +add_test(NAME test-jit-rc-owned-transfer COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_transfer.ts") +add_test(NAME test-jit-none-owned-transfer COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_transfer.ts") +add_test(NAME test-jit-rc-owned-call-results COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_call_results.ts") +add_test(NAME test-jit-none-owned-call-results COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_call_results.ts") +add_test(NAME test-jit-rc-owned-temporaries COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_temporaries.ts") +add_test(NAME test-jit-none-owned-temporaries COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_temporaries.ts") +add_test(NAME test-jit-rc-owned-interfaces COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_interfaces.ts") +add_test(NAME test-jit-none-owned-interfaces COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_interfaces.ts") +add_test(NAME test-jit-rc-owned-closures COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_closures.ts") +add_test(NAME test-jit-none-owned-closures COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_closures.ts") +add_test(NAME test-jit-rc-owned-any-boxing COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") +add_test(NAME test-jit-none-owned-any-boxing COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") +add_test(NAME test-jit-rc-owned-strings COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") +add_test(NAME test-jit-none-owned-strings COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") +add_test(NAME test-jit-rc-owned-generators COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") +add_test(NAME test-jit-none-owned-generators COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") +add_test(NAME test-jit-rc-owned-globals COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") +add_test(NAME test-jit-none-owned-globals COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") +add_test(NAME test-jit-rc-owned-generator-locals COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") +add_test(NAME test-jit-none-owned-generator-locals COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") +add_test(NAME test-jit-rc-owned-iteration COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_iteration.ts") +add_test(NAME test-jit-none-owned-iteration COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_iteration.ts") +add_test(NAME test-jit-rc-owned-async COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_async.ts") +add_test(NAME test-jit-none-owned-async COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_async.ts") +add_test(NAME test-jit-rc-owned-nested-captures COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_nested_captures.ts") +add_test(NAME test-jit-none-owned-nested-captures COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_nested_captures.ts") +add_test(NAME test-jit-rc-owned-unions COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_unions.ts") +add_test(NAME test-jit-none-owned-unions COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_unions.ts") +add_test(NAME test-jit-rc-owned-delete COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_delete.ts") +add_test(NAME test-jit-none-owned-delete COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_delete.ts") +add_test(NAME test-jit-rc-owned-early-return COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_early_return.ts") +add_test(NAME test-jit-none-owned-early-return COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_early_return.ts") +add_test(NAME test-jit-rc-owned-construct-interface COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_construct_interface.ts") +add_test(NAME test-jit-none-owned-construct-interface COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_construct_interface.ts") +add_test(NAME test-jit-rc-try-using-catch COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_using_catch.ts") +add_test(NAME test-jit-none-try-using-catch COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_using_catch.ts") +add_test(NAME test-jit-rc-disposable-scopes COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/04disposable.ts") +add_test(NAME test-jit-none-disposable-scopes COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/04disposable.ts") +add_test(NAME test-jit-rc-disposable-unwind COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/03disposable.ts") +add_test(NAME test-jit-none-disposable-unwind COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/03disposable.ts") + +# Allocating inside a Win64 catch/finally funclet - the shape that only faults once the model +# stops going through the collector, so it needs both non-gc variants to be worth anything. +add_test(NAME test-compile-00-alloc-in-catch COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00alloc_in_catch.ts") +add_test(NAME test-jit-00-alloc-in-catch COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00alloc_in_catch.ts") +add_test(NAME test-jit-rc-alloc-in-catch COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00alloc_in_catch.ts") +add_test(NAME test-jit-none-alloc-in-catch COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00alloc_in_catch.ts") + +# A try/catch inside a catch clause - crashed in every model at every optimisation level. +add_test(NAME test-compile-00-nested-catch COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00nested_catch.ts") +add_test(NAME test-jit-00-nested-catch COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00nested_catch.ts") +add_test(NAME test-jit-rc-nested-catch COMMAND test-runner -jit -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/00nested_catch.ts") +add_test(NAME test-jit-none-nested-catch COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00nested_catch.ts") + +# `-mm=none` - leak everything - had no coverage at all before the rename that gave it a name +# of its own. One test, so a future change to the model plumbing cannot silently break it. +add_test(NAME test-jit-none-strings COMMAND test-runner -jit -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/00strings.ts") + +# ============================================================================ +# The corpus, under the models that are not the default +# +# `-mm=rc` ran 42 files of this corpus and `-mm=none` ran 24, and neither ran a +# single one ahead of time. That is why three faults in a row were found by an +# out-of-tree sweep instead of here, and why the last of them was invisible +# twice over - once for the model, and once for the build mode. Every file the +# default model runs in a tier is run again below, in the same tier, under both +# other models. See docs/reference-counting-evaluation.md, plan item 5y. +# +# A file that does not pass yet under a model is listed in the matching BROKEN +# set below, and registered but disabled - so what is broken is written down in +# the build rather than in a document somebody has to find. +# ============================================================================ + +# every single-file test the default model runs, in either tier +set(TSLANG_CORPUS + 00add_promotes_both_operands.ts + 00conditional_owned_result.ts + 00owned_array_splice.ts + 00alloc_in_catch.ts + 00any_compare.ts + 00any_generic_equals.ts + 00any_types.ts + 00any.ts + 00array_assignment5.ts + 00array_cond_access.ts + 00array_of.ts + 00array_shift.ts + 00array_splice.ts + 00array_view.ts + 00array.ts + 00array2.ts + 00array3.ts + 00array4_push_pop.ts + 00array5_deconst.ts + 00array6.ts + 00array7.ts + 00array8_tuple_spread.ts + 00arrow_generic.ts + 00arrow_global.ts + 00arrow_in_arrow.ts + 00as_const.ts + 00as.ts + 00assert.ts + 00async_await.ts + 00async_gc_threading.ts + 00async_result_types.ts + 00bool_arith_ops.ts + 00break_continue_scope_exit.ts + 00break_continue.ts + 00capture_in_new_arguments.ts + 00catch_value.ts + 00catch_value_minimal.ts + 00class_abstract.ts + 00class_access_control.ts + 00class_accessor_super.ts + 00class_accessor_virtual.ts + 00class_accessor.ts + 00class_accessor2.ts + 00class_constr_fields.ts + 00class_deconst.ts + 00class_def_constr.ts + 00class_discover_types.ts + 00class_expression.ts + 00class_expression2.ts + 00class_expression3.ts + 00class_from_tuple.ts + 00class_generic_iface.ts + 00class_generic_method.ts + 00class_generic_method2.ts + 00class_generic.ts + 00class_iface.ts + 00class_indexer_super.ts + 00class_indexer.ts + 00class_iterator_super.ts + 00class_iterator.ts + 00class_local_decl.ts + 00class_nested.ts + 00class_new.ts + 00class_or_interface_to_tuple.ts + 00class_protected_constr.ts + 00class_recursive.ts + 00class_stack.ts + 00class_static_block.ts + 00class_static_constr.ts + 00class_static_generic_method.ts + 00class_static_generic_method2.ts + 00class_static_generic_method3.ts + 00class_structural_extends.ts + 00class_super_static.ts + 00class_super.ts + 00class_symbol_iterator.ts + 00class_var_args.ts + 00class_virtual_call.ts + 00class_virtual_table.ts + 00class.ts + 00computedpropertyname.ts + 00cond_expr.ts + 00conditional_type.ts + 00decorators.ts + 00disposable.ts + 00dowhile.ts + 00enum_multiple.ts + 00enum.ts + 00equals.ts + 00every.ts + 00extends.ts + 00extension_cond_access.ts + 00extension.ts + 00filter.ts + 00for_await_yield.ts + 00for_await.ts + 00for_in.ts + 00for_infinite_result.ts + 00for_of.ts + 00for.ts + 00funcs_bindings.ts + 00funcs_capture.ts + 00funcs_expression_generic.ts + 00funcs_expression_iterator.ts + 00funcs_generic_arrow.ts + 00funcs_generic_iterator.ts + 00funcs_generic_with_typeof.ts + 00funcs_generic.ts + 00funcs_nesting_capture.ts + 00funcs_nesting_generic.ts + 00funcs_nesting.ts + 00funcs_typed_generic_iterator.ts + 00funcs_typeless_function_as_generic.ts + 00funcs_vararg.ts + 00funcs.ts + 00generator_manual_next.ts + 00generator_manual_next2.ts + 00generator.ts + 00generator2.ts + 00generator3.ts + 00generator4.ts + 00generator5.ts + 00generator6.ts + 00generator7.ts + 00generic_arguments_name_conflict.ts + 00global_const_object_method.ts + 00globals.ts + 00globals2.ts + 00globals3.ts + 00globals_entry_point.ts + 00if_conditional_compile.ts + 00if_return.ts + 00in_method_names.ts + 00in.ts + 00infer.ts + 00instanceof.ts + 00interface_captures.ts + 00interface_conjunction.ts + 00interface_function_typed_field.ts + 00interface_generic.ts + 00interface_global_method.ts + 00interface_indexer.ts + 00interface_new.ts + 00interface_object_array.ts + 00interface_object.ts + 00interface_object2.ts + 00interface_object3.ts + 00interface_object4.ts + 00interface_object5.ts + 00interface_optional_cast_order.ts + 00interface_optional_extends.ts + 00interface_optional_method_extends.ts + 00interface_optional.ts + 00interface_partial.ts + 00interface.ts + 00interface2.ts + 00intersection_type_generic.ts + 00intersection_type.ts + 00iterator_bug.ts + 00iterator.ts + 00lambdas_generic_global.ts + 00lambdas.ts + 00length.ts + 00map.ts + 00method_access_cond.ts + 00mixed_type_ops.ts + 00names_conflict.ts + 00nested_catch.ts + 00new_delete.ts + 00ns_bug.ts + 00ns.ts + 00ns2.ts + 00ns3.ts + 00ns4.ts + 00ns5.ts + 00numbers.ts + 00object_accessor.ts + 00object_annotated_method_extends_interface_multilevel.ts + 00object_annotated_method_extends_interface.ts + 00object_annotated_method_interleaved.ts + 00object_annotated_method_params.ts + 00object_annotated_method.ts + 00object_boxed_infra.ts + 00object_chained_sibling_method_return.ts + 00object_deconst.ts + 00object_func.ts + 00object_func2.ts + 00object_func3.ts + 00object_global.ts + 00object_new.ts + 00object_ref_semantics.ts + 00object.ts + 00optional.ts + 00owned_any_boxing.ts + 00owned_array_ops.ts + 00owned_async.ts + 00owned_call_results.ts + 00owned_closures.ts + 00owned_construct_interface.ts + 00owned_delete.ts + 00owned_early_return.ts + 00owned_elements.ts + 00owned_fields.ts + 00owned_generator_locals.ts + 00owned_generators.ts + 00owned_globals.ts + 00owned_inline_records.ts + 00owned_interfaces.ts + 00owned_iteration.ts + 00owned_literals.ts + 00owned_locals.ts + 00owned_nested_captures.ts + 00owned_strings.ts + 00owned_temporaries.ts + 00owned_unions.ts + 00owned_transfer.ts + 00prefix_postfix.ts + 00print.ts + 00property_access_cond.ts + 00question_question.ts + 00reduce.ts + 00reference_index_bug.ts + 00reference_null_bug.ts + 00reference_ref_deref.ts + 00safe_cast_bug.ts + 00safe_cast_field_access.ts + 00safe_cast_typeof.ts + 00safe_cast_while.ts + 00safe_cast.ts + 00safe_cast2.ts + 00sizeof.ts + 00spread_assignment.ts + 00spread.ts + 00stack_test.ts + 00str_null.ts + 00strings.ts + 00switch.ts + 00symbol.ts + 00throw_in_catch.ts + 00throw_inlined.ts + 00to_primitive_ops.ts + 00toplevel_control_flow.ts + 00toplevel_no_main_with_helpers.ts + 00try_catch_mismatch_rethrow.ts + 00try_catch_rethrow.ts + 00try_catch_return_dispose.ts + 00try_catch_return.ts + 00try_catch.ts + 00try_finally_break_continue.ts + 00try_finally_return.ts + 00try_finally.ts + 00try_using_catch.ts + 00tuple_named.ts + 00tuple_with_array.ts + 00tuple.ts + 00type_aliases_in_generics.ts + 00type_guard_function.ts + 00typed_array.ts + 00types_indexedaccesstype.ts + 00types_keyof_enum.ts + 00types_mappedtype.ts + 00types_templateliteraltype.ts + 00types_unknown1.ts + 00types_utility.ts + 00types.ts + 00uint_compare_bug.ts + 00undef.ts + 00union_bin_ops.ts + 00union_bin_ops2.ts + 00union_errors.ts + 00union_ops.ts + 00union_to_any.ts + 00union_type.ts + 00using_nested_scopes.ts + 00using_unwind_progress.ts + 00var_bindings.ts + 00vars.ts + 00void.ts + 00while.ts + 01any.ts + 01arguments.ts + 01class_new.ts + 01disposable.ts + 01enum.ts + 01extends.ts + 01extension.ts + 01funcs_generic_iterator.ts + 01funcs_generic.ts + 01funcs_vararg.ts + 01iterator.ts + 01lambdas.ts + 01map.ts + 01method_access_cond.ts + 01optional.ts + 01print-bug.ts + 01reduce.ts + 01safe_cast_while.ts + 01sizeof.ts + 01spread_assignment.ts + 01spread.ts + 01switch.ts + 01symbol.ts + 01try_catch_return_dispose.ts + 01try_catch.ts + 01try_finally.ts + 01tuple.ts + 01types_mappedtype.ts + 01types_templateliteraltype.ts + 01types_utility.ts + 01union_type.ts + 02disposable.ts + 02extends.ts + 02funcs_generic_iterator.ts + 02funcs_vararg.ts + 02iterator.ts + 02numbers.ts + 02sizeof.ts + 02union_type.ts + 03disposable.ts + 03iterator.ts + 03union_type.ts + 03union_type_case_order.ts + 04disposable.ts + 04union_type.ts + 05strings.ts + 05union_type.ts + 06numbercollections.ts + 07stringcollections.ts + 08stringopertations.ts + 09postprefix.ts + 10arrayincrement.ts + 11equalsoperator.ts + 12referencecollection.ts + 13actions.ts + 14lazyoperations.ts + 15references_capture.ts + 15references.ts + 17classes.ts + 18enums.ts + 19forof_capture.ts + 19forof.ts + 20maps.ts + 22lambdas.ts + 23generics.ts + 241arrayforeach.ts + 243arrayevery.ts + 244arraysome.ts + 25lamdacapture.ts + 26staticclasses.ts + 28boolcasts.ts + 29lazyreferences.ts + 32complexcalls.ts + 33inheritance.ts + 34switch.ts + 35lambdaparameters.ts + 36interfaces.ts + 39objectdestructuring.ts + 40generics.ts + 41anonymoustypes.ts + 42lambdaproperties.ts + 43nestednamespace.ts + 44toplevelcode.ts + 45enumtostring.ts + 48instanceof.ts + 51exceptions.ts + abstractPropertyInConstructor.ts + additionOperatorWithNumberAndEnum.ts + arithmeticOperatorWithEnum.ts + arithmeticOperatorWithTypeParameter.ts + arrayFakeFlatNoCrashInferenceDeclarations.ts + arrayLiterals.ts + arrayLiterals2ES5.ts + arrayLiterals3.ts + assignmentTypeNarrowing.ts + asyncMethodWithSuper_es2017.ts + callWithSpread.ts + comparisonOperatorWithIdenticalObjects.ts + conditionalTypes1.ts + conditionalTypes2.ts + declarationsAndAssignments.ts + dependencies.ts + disallowLineTerminatorBeforeArrow.ts + emitDefaultParametersFunctionExpression.ts + gc_malloc_cse_o3.ts + Grammar_and_types.ts + internals.ts + load_store_decorators.ts + logicalAssignment5_2.ts + logicalAssignment5_3.ts + logicalAssignment5.ts + nbody.ts + newWithSpread.ts + no_main.ts + noPropertyAccessFromIndexSignature1.ts + parser.ts + path.ts + raytrace.ts + structural-typing.ts + thisTypeInClasses.ts + typeGuardFunction.ts + typeGuardOfFormThisMember.ts + typeGuardOfFormTypeOfBoolean.ts + types_vs_interfaces.ts + ) + +# the default model runs these ahead of time only, so the other models do too +set(TSLANG_CORPUS_NO_JIT + 00array_cond_access.ts + 00try_catch_mismatch_rethrow.ts + ) + +# and this one in the JIT only +set(TSLANG_CORPUS_NO_AOT + 02funcs_vararg.ts + ) + +# Files with a hand-written `-mm=rc` JIT entry further up. They keep the names the +# evaluation document cites, so the generated pass skips them rather than running +# each of them twice. +set(TSLANG_CORPUS_RC_NAMED + 00alloc_in_catch.ts + 00any_compare.ts + 00any_types.ts + 00any.ts + 00array_splice.ts + 00array.ts + 00array4_push_pop.ts + 00break_continue_scope_exit.ts + 00class.ts + 00for_of.ts + 00generator.ts + 00interface.ts + 00nested_catch.ts + 00new_delete.ts + 00owned_any_boxing.ts + 00owned_array_ops.ts + 00owned_async.ts + 00owned_call_results.ts + 00owned_closures.ts + 00owned_construct_interface.ts + 00owned_delete.ts + 00owned_early_return.ts + 00owned_elements.ts + 00owned_fields.ts + 00owned_generator_locals.ts + 00owned_generators.ts + 00owned_globals.ts + 00owned_inline_records.ts + 00owned_interfaces.ts + 00owned_iteration.ts + 00owned_literals.ts + 00owned_locals.ts + 00owned_nested_captures.ts + 00owned_strings.ts + 00owned_temporaries.ts + 00owned_unions.ts + 00owned_transfer.ts + 00print.ts + 00str_null.ts + 00strings.ts + 00throw_in_catch.ts + 00throw_inlined.ts + 00try_catch.ts + 00try_using_catch.ts + 00tuple.ts + 00union_type.ts + 00using_nested_scopes.ts + 00using_unwind_progress.ts + 03disposable.ts + 04disposable.ts + ) + +# the same, for `-mm=none` +set(TSLANG_CORPUS_NONE_NAMED + 00alloc_in_catch.ts + 00break_continue_scope_exit.ts + 00nested_catch.ts + 00owned_any_boxing.ts + 00owned_array_ops.ts + 00owned_async.ts + 00owned_call_results.ts + 00owned_closures.ts + 00owned_construct_interface.ts + 00owned_delete.ts + 00owned_early_return.ts + 00owned_elements.ts + 00owned_fields.ts + 00owned_generator_locals.ts + 00owned_generators.ts + 00owned_globals.ts + 00owned_inline_records.ts + 00owned_interfaces.ts + 00owned_iteration.ts + 00owned_literals.ts + 00owned_nested_captures.ts + 00owned_strings.ts + 00owned_temporaries.ts + 00owned_unions.ts + 00owned_transfer.ts + 00strings.ts + 00throw_inlined.ts + 00try_using_catch.ts + 00using_nested_scopes.ts + 00using_unwind_progress.ts + 03disposable.ts + 04disposable.ts + ) + +# Nothing is known broken. The lists are kept because they are how a reference-counting fault +# gets written down while it is being worked on: a file that does not pass yet under a model +# goes here and is registered but DISABLED, so ctest counts it out loud and the list of what is +# broken lives in the build rather than in a document somebody has to find. +# +# Not WILL_FAIL, which was tried first and does not hold - a corrupted heap does not always +# land, so a file can fail six runs in six on its own and come up clean in a suite run, where +# twelve tests at a time give the allocator a different history, and a WILL_FAIL test that +# passes is a red suite. +# +# Six files came off these lists. Section 9.48 took `00mixed_type_ops.ts` (a boxing cast +# reported no memory effects, so CSE merged two boxes over one constant and the block was freed +# twice), section 9.49 took `nbody.ts` and with it the two that wandered, `13actions.ts` and +# `44toplevelcode.ts` (a global never took a reference to what was stored into it), and section +# 9.50 took `00spread.ts` (a generator's locals live in its state object, which released what +# nothing had retained, and a by-value capture took a reference to the cell instead of to the +# value the box would release). +set(TSLANG_CORPUS_BROKEN_JIT_RC + ) + +set(TSLANG_CORPUS_BROKEN_JIT_NONE + ) + +set(TSLANG_CORPUS_BROKEN_AOT_RC + ) + +set(TSLANG_CORPUS_BROKEN_AOT_NONE + ) + +foreach(corpus_file ${TSLANG_CORPUS}) + string(REGEX REPLACE "\\.ts$" "" corpus_slug "${corpus_file}") + string(REGEX REPLACE "[_.]" "-" corpus_slug "${corpus_slug}") + set(corpus_path "${PROJECT_SOURCE_DIR}/test/tester/tests/${corpus_file}") + + foreach(corpus_model rc none) + string(TOUPPER "${corpus_model}" corpus_MODEL) + + if(NOT corpus_file IN_LIST TSLANG_CORPUS_NO_JIT AND + NOT corpus_file IN_LIST TSLANG_CORPUS_${corpus_MODEL}_NAMED) + set(corpus_test "test-jit-${corpus_model}-corpus-${corpus_slug}") + add_test(NAME ${corpus_test} + COMMAND test-runner -jit -mm=${corpus_model} "${corpus_path}") + if(corpus_file IN_LIST TSLANG_CORPUS_BROKEN_JIT_${corpus_MODEL}) + set_tests_properties(${corpus_test} PROPERTIES DISABLED TRUE) + endif() + endif() + + if(NOT corpus_file IN_LIST TSLANG_CORPUS_NO_AOT) + set(corpus_test "test-compile-${corpus_model}-corpus-${corpus_slug}") + add_test(NAME ${corpus_test} + COMMAND test-runner -mm=${corpus_model} "${corpus_path}") + if(corpus_file IN_LIST TSLANG_CORPUS_BROKEN_AOT_${corpus_MODEL}) + set_tests_properties(${corpus_test} PROPERTIES DISABLED TRUE) + endif() + endif() + endforeach() +endforeach() + +# `fast_math.ts` is the one corpus file the loop leaves out. It needs `-fast-math`, which +# would give the runner a fourth and fifth cached link script (`jitfmrc`, `jitfmnone`, and +# their compile and debug variants) for a test about floating point, where the memory model +# has nothing to do with the answer. + +# The ownership verifier over every corpus file. It is the only check here that reads the IR +# rather than the program's output, which is what makes it worth running: a reference nobody +# gives back changes no answer, so nothing else in this suite can see one. See +# verify-ownership.cmake for what it asks and why it is sharded. +# Reference-counted programs built WITH debug info - see debug-info-rc.cmake for why this needs +# a test of its own rather than a `test-runner` registration. +add_test(NAME test-compile-rc-debug-info + COMMAND ${CMAKE_COMMAND} + -DTSLANG=$ + -DTESTS_DIR=${PROJECT_SOURCE_DIR}/test/tester/tests + -P ${CMAKE_CURRENT_SOURCE_DIR}/debug-info-rc.cmake) + +set(TSLANG_OWNERSHIP_SHARDS 8) +math(EXPR ownership_last "${TSLANG_OWNERSHIP_SHARDS} - 1") +foreach(ownership_shard RANGE ${ownership_last}) + add_test(NAME test-ownership-verifier-${ownership_shard} + COMMAND ${CMAKE_COMMAND} + "-DTSLANG=$" + "-DTESTS_DIR=${PROJECT_SOURCE_DIR}/test/tester/tests" + "-DSHARD=${ownership_shard}" + "-DSHARDS=${TSLANG_OWNERSHIP_SHARDS}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/verify-ownership.cmake") +endforeach() + +# The shared-component tier under the other two models. A shared library records the model +# it was built under, so both halves of a pair are built with the same flag - which is what +# these run. The file pairs are the default model's, verbatim. +add_test(NAME test-compile-time-rc-shared-decl-emit-type COMMAND test-runner -shared -mm=rc -compile-time "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_compiletime_func.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_type.ts") +add_test(NAME test-compile-time-none-shared-decl-emit-type COMMAND test-runner -shared -mm=none -compile-time "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_compiletime_func.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_type.ts") +add_test(NAME test-compile-time-rc-shared-decl-emit-class COMMAND test-runner -shared -mm=rc -compile-time "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_compiletime_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_class.ts") +add_test(NAME test-compile-time-none-shared-decl-emit-class COMMAND test-runner -shared -mm=none -compile-time "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_compiletime_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_class.ts") +add_test(NAME test-compile-rc-shared-component COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/use_shared.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/shared.ts") +add_test(NAME test-compile-none-shared-component COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/use_shared.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/shared.ts") +add_test(NAME test-compile-rc-shared-decl-emit-interface COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_interface.ts") +add_test(NAME test-compile-none-shared-decl-emit-interface COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_interface.ts") +add_test(NAME test-compile-rc-shared-decl-emit-type COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_type.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_type.ts") +add_test(NAME test-compile-none-shared-decl-emit-type COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_type.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_type.ts") +add_test(NAME test-compile-rc-shared-decl-emit-enum COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_enum.ts") +add_test(NAME test-compile-none-shared-decl-emit-enum COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_enum.ts") +add_test(NAME test-compile-rc-shared-decl-emit-class COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_class.ts") +add_test(NAME test-compile-none-shared-decl-emit-class COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_class.ts") +add_test(NAME test-compile-rc-shared-export-import-class-interface COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +add_test(NAME test-compile-none-shared-export-import-class-interface COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-with-class-types COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_class_types.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_class_types.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-with-class-types COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_class_types.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_class_types.ts") +add_test(NAME test-compile-rc-shared-export-import-owned-returns COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") +add_test(NAME test-compile-none-shared-export-import-owned-returns COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") +add_test(NAME test-compile-rc-shared-export-import-class-extends COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") +add_test(NAME test-compile-none-shared-export-import-class-extends COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") +add_test(NAME test-compile-rc-shared-export-import-class-extends-implements-diamond COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") +add_test(NAME test-compile-none-shared-export-import-class-extends-implements-diamond COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") +add_test(NAME test-compile-rc-shared-export-import-class-extends-multilevel COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") +add_test(NAME test-compile-none-shared-export-import-class-extends-multilevel COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") +add_test(NAME test-compile-rc-shared-export-import-class-abstract COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract.ts") +add_test(NAME test-compile-none-shared-export-import-class-abstract COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract.ts") +add_test(NAME test-compile-rc-shared-export-import-class-static COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_static.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_static.ts") +add_test(NAME test-compile-none-shared-export-import-class-static COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_static.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_static.ts") +add_test(NAME test-compile-rc-shared-export-import-class-implements-interface-multilevel COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_multilevel.ts") +add_test(NAME test-compile-none-shared-export-import-class-implements-interface-multilevel COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_multilevel.ts") +add_test(NAME test-compile-rc-shared-export-import-class-implements-interface-optional COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_optional.ts") +add_test(NAME test-compile-none-shared-export-import-class-implements-interface-optional COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_optional.ts") +add_test(NAME test-compile-rc-shared-export-import-class-structural-interface COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_structural_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_structural_interface.ts") +add_test(NAME test-compile-none-shared-export-import-class-structural-interface COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_structural_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_structural_interface.ts") +add_test(NAME test-compile-rc-shared-export-import-class-implements-interface-abstract COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_abstract.ts") +add_test(NAME test-compile-none-shared-export-import-class-implements-interface-abstract COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_abstract.ts") +add_test(NAME test-compile-rc-shared-export-import-class-abstract-virtual-dispatch COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract_virtual_dispatch.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract_virtual_dispatch.ts") +add_test(NAME test-compile-none-shared-export-import-class-abstract-virtual-dispatch COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract_virtual_dispatch.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract_virtual_dispatch.ts") +add_test(NAME test-compile-rc-shared-export-import-class-generic COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_generic.ts") +add_test(NAME test-compile-none-shared-export-import-class-generic COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_generic.ts") +add_test(NAME test-compile-rc-shared-export-import-function-generic COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function_generic.ts") +add_test(NAME test-compile-none-shared-export-import-function-generic COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function_generic.ts") +add_test(NAME test-compile-rc-shared-export-import-type-alias-generic COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias_generic.ts") +add_test(NAME test-compile-none-shared-export-import-type-alias-generic COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias_generic.ts") +add_test(NAME test-compile-rc-shared-export-import-function COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function.ts") +add_test(NAME test-compile-none-shared-export-import-function COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function.ts") +add_test(NAME test-compile-rc-shared-export-import-type-alias COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") +add_test(NAME test-compile-none-shared-export-import-type-alias COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") +add_test(NAME test-compile-rc-shared-export-import-interface-generic COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") +add_test(NAME test-compile-none-shared-export-import-interface-generic COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") +add_test(NAME test-compile-rc-shared-export-import-class-accessor COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_accessor.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_accessor.ts") +add_test(NAME test-compile-none-shared-export-import-class-accessor COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_accessor.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_accessor.ts") +add_test(NAME test-compile-rc-shared-export-import-class-indexer COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_indexer.ts") +add_test(NAME test-compile-none-shared-export-import-class-indexer COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_indexer.ts") +add_test(NAME test-compile-rc-shared-export-import-interface-indexer COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_indexer.ts") +add_test(NAME test-compile-none-shared-export-import-interface-indexer COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_indexer.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-with-interface COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_interface.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-with-interface COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_interface.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-untyped COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-untyped COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-untyped-multi-method COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped_multi_method.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-untyped-multi-method COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped_multi_method.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-params COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_params.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_params.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-params COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_params.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_params.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-multi-method COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_multi_method.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-multi-method COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_multi_method.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-interleaved COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_interleaved.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_interleaved.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-interleaved COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_interleaved.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_interleaved.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-extends-interface COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-extends-interface COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-extends-interface-multilevel COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_multilevel.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-extends-interface-multilevel COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_multilevel.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-extends-interface-diamond COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_diamond.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-extends-interface-diamond COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_diamond.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-extends-interface-triple COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_triple.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_triple.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-extends-interface-triple COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_triple.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_triple.ts") +add_test(NAME test-compile-rc-shared-export-import-object-literal-structural-typed-extends-interface-optional COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_optional.ts") +add_test(NAME test-compile-none-shared-export-import-object-literal-structural-typed-extends-interface-optional COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_optional.ts") +add_test(NAME test-compile-rc-shared-export-import-vars COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars.ts") +add_test(NAME test-compile-none-shared-export-import-vars COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars.ts") +add_test(NAME test-compile-rc-shared-export-import-vars-2 COMMAND test-runner -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars2.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars2.ts") +add_test(NAME test-compile-none-shared-export-import-vars-2 COMMAND test-runner -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars2.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars2.ts") +add_test(NAME test-compile-rc-shared-export-import-enum COMMAND test-runner -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_enum.ts") +add_test(NAME test-compile-none-shared-export-import-enum COMMAND test-runner -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_enum.ts") +add_test(NAME test-jit-rc-shared-component COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/use_shared.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/shared.ts") +add_test(NAME test-jit-none-shared-component COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/use_shared.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/shared.ts") +add_test(NAME test-jit-rc-shared-decl-emit-interface COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_interface.ts") +add_test(NAME test-jit-none-shared-decl-emit-interface COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_interface.ts") +add_test(NAME test-jit-rc-shared-decl-emit-type COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_type.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_type.ts") +add_test(NAME test-jit-none-shared-decl-emit-type COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_type.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_type.ts") +add_test(NAME test-jit-rc-shared-decl-emit-enum COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_enum.ts") +add_test(NAME test-jit-none-shared-decl-emit-enum COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_enum.ts") +add_test(NAME test-jit-rc-shared-decl-emit-class COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_class.ts") +add_test(NAME test-jit-none-shared-decl-emit-class COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/emit_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/decl_class.ts") +add_test(NAME test-jit-rc-shared-export-import-class-interface COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +add_test(NAME test-jit-none-shared-export-import-class-interface COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-with-class-types COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_class_types.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_class_types.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-with-class-types COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_class_types.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_class_types.ts") +add_test(NAME test-jit-rc-shared-export-import-owned-returns COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") +add_test(NAME test-jit-none-shared-export-import-owned-returns COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_owned_returns.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_owned_returns.ts") +add_test(NAME test-jit-rc-shared-export-import-class-extends COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") +add_test(NAME test-jit-none-shared-export-import-class-extends COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends.ts") +add_test(NAME test-jit-rc-shared-export-import-class-extends-multilevel COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") +add_test(NAME test-jit-none-shared-export-import-class-extends-multilevel COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_multilevel.ts") +add_test(NAME test-jit-rc-shared-export-import-class-extends-implements-diamond COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") +add_test(NAME test-jit-none-shared-export-import-class-extends-implements-diamond COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_extends_implements_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_extends_implements_diamond.ts") +add_test(NAME test-jit-rc-shared-export-import-class-abstract COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract.ts") +add_test(NAME test-jit-none-shared-export-import-class-abstract COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract.ts") +add_test(NAME test-jit-rc-shared-export-import-class-static COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_static.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_static.ts") +add_test(NAME test-jit-none-shared-export-import-class-static COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_static.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_static.ts") +add_test(NAME test-jit-rc-shared-export-import-class-implements-interface-multilevel COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_multilevel.ts") +add_test(NAME test-jit-none-shared-export-import-class-implements-interface-multilevel COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_multilevel.ts") +add_test(NAME test-jit-rc-shared-export-import-class-implements-interface-optional COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_optional.ts") +add_test(NAME test-jit-none-shared-export-import-class-implements-interface-optional COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_optional.ts") +add_test(NAME test-jit-rc-shared-export-import-class-structural-interface COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_structural_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_structural_interface.ts") +add_test(NAME test-jit-none-shared-export-import-class-structural-interface COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_structural_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_structural_interface.ts") +add_test(NAME test-jit-rc-shared-export-import-class-implements-interface-abstract COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_abstract.ts") +add_test(NAME test-jit-none-shared-export-import-class-implements-interface-abstract COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_implements_interface_abstract.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_implements_interface_abstract.ts") +add_test(NAME test-jit-rc-shared-export-import-class-abstract-virtual-dispatch COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract_virtual_dispatch.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract_virtual_dispatch.ts") +add_test(NAME test-jit-none-shared-export-import-class-abstract-virtual-dispatch COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_abstract_virtual_dispatch.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_abstract_virtual_dispatch.ts") +add_test(NAME test-jit-rc-shared-export-import-class-generic COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_generic.ts") +add_test(NAME test-jit-none-shared-export-import-class-generic COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_generic.ts") +add_test(NAME test-jit-rc-shared-export-import-function-generic COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function_generic.ts") +add_test(NAME test-jit-none-shared-export-import-function-generic COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function_generic.ts") +add_test(NAME test-jit-rc-shared-export-import-type-alias-generic COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias_generic.ts") +add_test(NAME test-jit-none-shared-export-import-type-alias-generic COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias_generic.ts") +add_test(NAME test-jit-rc-shared-export-import-function COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function.ts") +add_test(NAME test-jit-none-shared-export-import-function COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function.ts") +add_test(NAME test-jit-rc-shared-export-import-type-alias COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") +add_test(NAME test-jit-none-shared-export-import-type-alias COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") +add_test(NAME test-jit-rc-shared-export-import-interface-generic COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") +add_test(NAME test-jit-none-shared-export-import-interface-generic COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") +add_test(NAME test-jit-rc-shared-export-import-class-accessor COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_accessor.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_accessor.ts") +add_test(NAME test-jit-none-shared-export-import-class-accessor COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_accessor.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_accessor.ts") +add_test(NAME test-jit-rc-shared-export-import-class-indexer COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_indexer.ts") +add_test(NAME test-jit-none-shared-export-import-class-indexer COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_indexer.ts") +add_test(NAME test-jit-rc-shared-export-import-interface-indexer COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_indexer.ts") +add_test(NAME test-jit-none-shared-export-import-interface-indexer COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_indexer.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_indexer.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-with-interface COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_interface.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-with-interface COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_with_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_with_interface.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-untyped COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-untyped COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-untyped-multi-method COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped_multi_method.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-untyped-multi-method COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_untyped_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_untyped_multi_method.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-params COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_params.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_params.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-params COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_params.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_params.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-multi-method COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_multi_method.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-multi-method COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_multi_method.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_multi_method.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-interleaved COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_interleaved.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_interleaved.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-interleaved COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_interleaved.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_interleaved.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-extends-interface COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-extends-interface COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-extends-interface-multilevel COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_multilevel.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-extends-interface-multilevel COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_multilevel.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_multilevel.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-extends-interface-diamond COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_diamond.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-extends-interface-diamond COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_diamond.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_diamond.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-extends-interface-triple COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_triple.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_triple.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-extends-interface-triple COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_triple.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_triple.ts") +add_test(NAME test-jit-rc-shared-export-import-object-literal-structural-typed-extends-interface-optional COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_optional.ts") +add_test(NAME test-jit-none-shared-export-import-object-literal-structural-typed-extends-interface-optional COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_object_literal_structural_typed_extends_interface_optional.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_object_literal_structural_typed_extends_interface_optional.ts") +add_test(NAME test-jit-rc-shared-export-import-vars COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars.ts") +add_test(NAME test-jit-none-shared-export-import-vars COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars.ts") +add_test(NAME test-jit-rc-shared-export-import-vars-2 COMMAND test-runner -jit -shared -mm=rc -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars2.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars2.ts") +add_test(NAME test-jit-none-shared-export-import-vars-2 COMMAND test-runner -jit -shared -mm=none -gctors-as-method "${PROJECT_SOURCE_DIR}/test/tester/tests/import_vars2.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_vars2.ts") +add_test(NAME test-jit-rc-shared-export-import-enum COMMAND test-runner -jit -shared -mm=rc "${PROJECT_SOURCE_DIR}/test/tester/tests/import_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_enum.ts") +add_test(NAME test-jit-none-shared-export-import-enum COMMAND test-runner -jit -shared -mm=none "${PROJECT_SOURCE_DIR}/test/tester/tests/import_enum.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_enum.ts") + +# Without the shared Boehm (see TSLANG_HAVE_SHARED_GC above) every -shared test fails to link +# with "could not open 'gc.lib'", so disable them instead. Only Windows links the shared +# collector. Every test named "-shared-" passes -shared and no other test does. +if (WIN32 AND NOT TSLANG_HAVE_SHARED_GC) + get_property(tslang_all_tests DIRECTORY PROPERTY TESTS) + foreach(tslang_test ${tslang_all_tests}) + if (tslang_test MATCHES "-shared-") + set_tests_properties(${tslang_test} PROPERTIES DISABLED TRUE) + endif() + endforeach() +endif() diff --git a/tslang/test/tester/debug-info-rc.cmake b/tslang/test/tester/debug-info-rc.cmake new file mode 100644 index 000000000..7b46a10b7 --- /dev/null +++ b/tslang/test/tester/debug-info-rc.cmake @@ -0,0 +1,53 @@ +# Emits LLVM IR for a few reference-counted programs WITH debug info. +# +# This combination has no other coverage in the suite. `test-runner` decides between `--opt` and +# `--di` from the build configuration rather than a flag, so a `-mm=rc` test registered through +# it gets whichever the current build uses and never both. That is why the failure of section +# 9.31 survived to section 9.69 as a one-line note: `--di --opt_level=0 -mm=rc` emitted no IR at +# all, for any reference-counted program, and nothing ran it. +# +# A compile, not a run: the bug was a module that would not translate, so reaching the end of +# emission is the whole assertion. Kept to a handful of files rather than the corpus because the +# routines that carry the bug - the generated `tsrel_`/`tsret_` pair and the `__tslang_*` +# helpers - are all reached by the shapes below, and a corpus-wide sweep would cost the suite +# thirty seconds to say the same thing. + +if(NOT DEFINED TSLANG OR NOT DEFINED TESTS_DIR) + message(FATAL_ERROR "TSLANG and TESTS_DIR are both required") +endif() + +set(files + # written for this: a string local, an owning field, an array, a closure, a class + 00owned_debug_info.ts + # the two section 9.31 named when it found the failure + 00owned_temporaries.ts + 00interface.ts + # `any` boxing and the closure/capture routines, which reach the other helpers + 00any.ts + 00owned_closures.ts + # the largest reference-counted program here + raytrace.ts) + +set(failures "") +foreach(name ${files}) + execute_process( + COMMAND "${TSLANG}" --emit=llvm --di --opt_level=0 -mm=rc --no-default-lib + "${TESTS_DIR}/${name}" -o=${name}.ll + OUTPUT_QUIET + ERROR_VARIABLE diagnostics + RESULT_VARIABLE status) + + if(NOT status EQUAL 0) + string(REGEX REPLACE "\n.*" "" first_line "${diagnostics}") + list(APPEND failures " ${name}: exit ${status}: ${first_line}") + endif() + + file(REMOVE "${name}.ll") +endforeach() + +if(failures) + string(REPLACE ";" "\n" report "${failures}") + message(FATAL_ERROR "debug info under -mm=rc:\n${report}") +endif() + +message(STATUS "debug info clean under -mm=rc over ${CMAKE_MATCH_COUNT} files") diff --git a/tslang/test/tester/test-runner.cpp b/tslang/test/tester/test-runner.cpp index 9834d1faa..8645307b4 100644 --- a/tslang/test/tester/test-runner.cpp +++ b/tslang/test/tester/test-runner.cpp @@ -1,4 +1,5 @@ #include "helper.h" +#include #ifndef WIN32 #include // for usleep #endif @@ -100,20 +101,26 @@ auto tslang_opt = "--di --opt_level=0 --no-default-lib"; #endif auto fastMath = false; +auto memoryModel = std::string(""); auto tslang_opt_ext = std::string(""); -// -fast-math tests get their own cached script (jitfm/compilefm) because the -// plain jit/compile scripts are shared across all parallel single-file tests -// and embed tslang_opt_ext at creation time - reusing the same file name would -// let whichever runner created it first decide the flags for everyone. +// Tests that pass extra compiler flags get their own cached script (jitfm/jitrc, and the +// compile equivalents) because the plain jit/compile scripts are shared across all parallel +// single-file tests and embed tslang_opt_ext at creation time - reusing the same file name +// would let whichever runner created it first decide the flags for everyone. +std::string optVariantSuffix() +{ + return std::string(fastMath ? "fm" : "") + memoryModel; +} + std::string jitBatName() { - return std::string(JIT_NAME) + (fastMath ? "fm" : "") + BAT_NAME; + return std::string(JIT_NAME) + optVariantSuffix() + BAT_NAME; } std::string compileBatName() { - return std::string(COMPILE_NAME) + (fastMath ? "fm" : "") + BAT_NAME; + return std::string(COMPILE_NAME) + optVariantSuffix() + BAT_NAME; } void createJitBatchFile() @@ -170,7 +177,7 @@ void createCompileBatchFile() batFile << "set TSLANGEXEPATH=" << TEST_TSLANG_EXEPATH << std::endl; batFile << "set TSLANG_LIB_PATH=" << TEST_TSLANG_LIBPATH << std::endl; batFile << "set GC_LIB_PATH=" << TEST_GCPATH << std::endl; - batFile << "%TSLANGEXEPATH%\\tslang.exe --emit=obj " << tslang_opt << " " << tslang_opt_ext << " %FILEPATH% -o=%FILENAME%.obj" << std::endl; + batFile << "%TSLANGEXEPATH%\\tslang.exe --emit=obj --entry-point " << tslang_opt << " " << tslang_opt_ext << " %FILEPATH% -o=%FILENAME%.obj" << std::endl; batFile << "%LLVMEXEPATH%\\lld.exe -flavor link %FILENAME%.obj %LINKER_OPTS% " << LIBS << TYPESCRIPT_LIB << GC_LIB << LLVM_LIBS << CMAKE_C_STANDARD_LIBRARIES << " /libpath:%GC_LIB_PATH% /libpath:%LLVM_LIB_PATH% /libpath:%TSLANG_LIB_PATH%" @@ -178,6 +185,7 @@ void createCompileBatchFile() << std::endl; batFile << "del %FILENAME%.obj" << std::endl; batFile << "call " RUN_CMD "%FILENAME%.exe 1> %FILENAME%.txt 2> %FILENAME%.err" << std::endl; + batFile << "echo %ERRORLEVEL% > %FILENAME%.code" << std::endl; batFile << "del %FILENAME%.exe" << std::endl; batFile << "if exist %FILENAME%.lib (del %FILENAME%.lib)" << std::endl; batFile << "if exist %FILENAME%.dll (del %FILENAME%.dll)" << std::endl; @@ -198,10 +206,11 @@ void createCompileBatchFile() batFile << "LLVM_EXEPATH=" << TEST_LLVM_EXEPATH << std::endl; batFile << "LLVM_LIBPATH=" << TEST_LLVM_LIBPATH << std::endl; batFile << "GC_LIB_PATH=" << TEST_GCPATH << std::endl; - batFile << "$TSLANGEXEPATH/tslang --emit=obj " << tslang_opt << " " << tslang_opt_ext << " $FILEPATH -relocation-model=pic -o=$FILENAME.o" << std::endl; + batFile << "$TSLANGEXEPATH/tslang --emit=obj --entry-point " << tslang_opt << " " << tslang_opt_ext << " $FILEPATH -relocation-model=pic -o=$FILENAME.o" << std::endl; batFile << TEST_COMPILER << " -o $FILENAME $LINKER_OPTS -L$LLVM_LIBPATH -L$GC_LIB_PATH -L$TSLANG_LIB_PATH $FILENAME.o " << TYPESCRIPT_LIB << GC_LIB << LLVM_LIBS << LIBS << std::endl; batFile << "./$FILENAME 1> $FILENAME.txt 2> $FILENAME.err" << std::endl; + batFile << "echo $? > $FILENAME.code" << std::endl; batFile << "rm -f $FILENAME.o" << std::endl; batFile << "rm -f $FILENAME" << std::endl; batFile.close(); @@ -253,9 +262,9 @@ void deleteFiles(std::string tempOutputFileNameNoExt) { std::stringstream mask; #if WIN32 - mask << "del " << tempOutputFileNameNoExt << ".bat " << tempOutputFileNameNoExt << ".txt " << tempOutputFileNameNoExt << ".err " << tempOutputFileNameNoExt << ".exe " << tempOutputFileNameNoExt << ".obj"; + mask << "del " << tempOutputFileNameNoExt << ".bat " << tempOutputFileNameNoExt << ".txt " << tempOutputFileNameNoExt << ".err " << tempOutputFileNameNoExt << ".code " << tempOutputFileNameNoExt << ".exe " << tempOutputFileNameNoExt << ".obj"; #else - mask << "rm -f " << tempOutputFileNameNoExt << ".sh " << tempOutputFileNameNoExt << ".txt " << tempOutputFileNameNoExt << ".err " << tempOutputFileNameNoExt << " " << tempOutputFileNameNoExt << ".o"; + mask << "rm -f " << tempOutputFileNameNoExt << ".sh " << tempOutputFileNameNoExt << ".txt " << tempOutputFileNameNoExt << ".err " << tempOutputFileNameNoExt << ".code " << tempOutputFileNameNoExt << " " << tempOutputFileNameNoExt << ".o"; #endif auto delCmd = mask.str(); @@ -266,12 +275,34 @@ std::string checkOutputAndCleanup(std::string tempOutputFileNameNoExt) { auto txtFile = tempOutputFileNameNoExt + ".txt"; auto errFile = tempOutputFileNameNoExt + ".err"; + auto codeFile = tempOutputFileNameNoExt + ".code"; auto output = readOutput(txtFile); auto errors = readOutput(errFile); + // written by the compile scripts only; a JIT run has no separate program to ask + auto exitCode = readOutput(codeFile); if (!getenv("TSLANG_TEST_KEEP_TEMP")) deleteFiles(tempOutputFileNameNoExt); + // A program that prints everything it was asked to and then tells the shell it failed is a + // failing program, and until this was checked nothing in the suite would say so: an + // ahead-of-time `-mm=rc` build exited 1 from a `main` returning nothing for as long as `rc` + // has existed, and every one of these tests passed. + if (!exitCode.empty()) + { + // `echo %ERRORLEVEL% > file` writes a trailing space before the newline, so this has to + // trim whitespace at both ends rather than just cut at the line break + auto first = exitCode.find_first_not_of(" \t\r\n"); + auto last = exitCode.find_last_not_of(" \t\r\n"); + auto trimmed = first == std::string::npos ? std::string() : exitCode.substr(first, last - first + 1); + if (!trimmed.empty() && trimmed != "0") + { + return "exit code " + trimmed + (output.find("done.") != std::string::npos + ? " from a run that printed 'done.'" + : ""); + } + } + if (output.find("done.") != std::string::npos) { return std::string(); @@ -306,18 +337,25 @@ std::string getTempOutputFileNameNoExt(std::string file) return fileNameNoExtWithMs; } +// Every throw here and below is a std::runtime_error rather than a string literal, and that is +// not a style choice: the only handlers in this file catch `const std::exception &`, so a +// `throw "..."` was never caught anywhere. It reached std::terminate, which on Windows is a +// __fastfail - the runner died with 0xC0000409 and printed nothing at all. That happened for an +// ordinary failing test (checkedExecCommand means to swallow this and let the missing "done." +// be the report) and for every misuse of the command line, where the message says exactly what +// is wrong and was never seen. void checkExecOutput(std::string compileResult) { auto index = compileResult.find("error:"); if (index != std::string::npos) { - throw "compile error"; + throw std::runtime_error("compile error"); } index = compileResult.find("failed"); if (index != std::string::npos) { - throw "run error"; + throw std::runtime_error("run error"); } } @@ -365,13 +403,24 @@ void createMultiCompileBatchFile(std::string tempOutputFileNameNoExt, std::vecto batFile << "set TSLANG_LIB_PATH=" << TEST_TSLANG_LIBPATH << std::endl; batFile << "set GC_LIB_PATH=" << TEST_GCPATH << std::endl; + // Same isolation the shared multi-file path uses, and for the same reason: the object files + // are named after the SOURCE stems, so two tests built from the same pair of sources - the + // gc, rc and none variants of one import/export pair, say - write and then delete each + // other's .obj under `ctest -j`. That only stayed hidden while every such pair was + // registered exactly once. The .txt/.err/.code output goes to the parent, where the runner + // reads it from. + batFile << "set WORKDIR=" << tempOutputFileNameNoExt << "_wd" << std::endl; + batFile << "if exist %WORKDIR% rmdir /s /q %WORKDIR%" << std::endl; + batFile << "mkdir %WORKDIR%" << std::endl; + batFile << "cd %WORKDIR%" << std::endl; + std::stringstream objs; auto isFirst = true; for (auto &file : files) { auto fileNameWithoutExt = fs::path(file).stem().string(); objs << fileNameWithoutExt << ".obj "; - batFile << "%TSLANGEXEPATH%\\tslang.exe --emit=obj " << tslang_opt << " " << (isFirst ? "" : tslang_opt_ext) << " " << file << " -o=" << fileNameWithoutExt << ".obj" << std::endl; + batFile << "%TSLANGEXEPATH%\\tslang.exe --emit=obj " << (isFirst ? "--entry-point " : "") << tslang_opt << " " << (isFirst ? "" : tslang_opt_ext) << " " << file << " -o=" << fileNameWithoutExt << ".obj" << std::endl; isFirst = false; } @@ -382,10 +431,14 @@ void createMultiCompileBatchFile(std::string tempOutputFileNameNoExt, std::vecto << std::endl; batFile << "del " << objs.str() << std::endl; - batFile << "call " RUN_CMD "%FILENAME%.exe 1> %FILENAME%.txt 2> %FILENAME%.err" << std::endl; + batFile << "call " RUN_CMD "%FILENAME%.exe 1> ..\\%FILENAME%.txt 2> ..\\%FILENAME%.err" << std::endl; + batFile << "echo %ERRORLEVEL% > ..\\%FILENAME%.code" << std::endl; batFile << "del %FILENAME%.exe" << std::endl; batFile << "if exist %FILENAME%.lib (del %FILENAME%.lib)" << std::endl; - batFile << "if exist %FILENAME%.dll (del %FILENAME%.dll)" << std::endl; + batFile << "if exist %FILENAME%.dll (del %FILENAME%.dll)" << std::endl; + batFile << "echo off" << std::endl; + batFile << "cd .." << std::endl; + batFile << "rmdir /s /q %WORKDIR%" << std::endl; batFile << "echo on" << std::endl; batFile.close(); #else @@ -404,7 +457,7 @@ void createMultiCompileBatchFile(std::string tempOutputFileNameNoExt, std::vecto // prefix with the unique temp name so parallel tests reusing the same source files don't stomp each other's object files auto fileNameWithoutExt = tempOutputFileNameNoExt + "_" + fs::path(file).stem().string(); objs << fileNameWithoutExt << ".o "; - batFile << "$TSLANGEXEPATH/tslang --emit=obj " << tslang_opt << " " << (isFirst ? "" : tslang_opt_ext) << " " << file << " -relocation-model=pic -o=" << fileNameWithoutExt << ".o" << std::endl; + batFile << "$TSLANGEXEPATH/tslang --emit=obj " << (isFirst ? "--entry-point " : "") << tslang_opt << " " << (isFirst ? "" : tslang_opt_ext) << " " << file << " -relocation-model=pic -o=" << fileNameWithoutExt << ".o" << std::endl; isFirst = false; } @@ -412,6 +465,7 @@ void createMultiCompileBatchFile(std::string tempOutputFileNameNoExt, std::vecto << "-L$LLVM_LIBPATH -L$GC_LIB_PATH -L$TSLANG_LIB_PATH " << TYPESCRIPT_LIB << GC_LIB << LLVM_LIBS << LIBS << std::endl; batFile << "./$FILENAME 1> $FILENAME.txt 2> $FILENAME.err" << std::endl; + batFile << "echo $? > $FILENAME.code" << std::endl; batFile << "rm -f " << objs.str() << std::endl; batFile << "rm -f $FILENAME" << std::endl; @@ -424,7 +478,9 @@ void createSharedMultiBatchFile(std::string tempOutputFileNameNoExt, std::vector { if (gctorsAsMethod) { - tslang_opt_ext += "--gctors-as-method"; + // the separator matters: `-mm=` may already have put something here, and without it + // the two run together into one unrecognised option + tslang_opt_ext += " --gctors-as-method"; } auto linker_opt = SHARED_LIB_OPT; @@ -440,7 +496,13 @@ void createSharedMultiBatchFile(std::string tempOutputFileNameNoExt, std::vector batFile << "set LLVM_LIB_PATH=" << TEST_LLVM_LIBPATH << std::endl; batFile << "set TSLANGEXEPATH=" << TEST_TSLANG_EXEPATH << std::endl; batFile << "set TSLANG_LIB_PATH=" << TEST_TSLANG_LIBPATH << std::endl; - batFile << "set GC_LIB_PATH=" << TEST_GCPATH << std::endl; + // The SHARED collector, and only here. Two binaries that each link gc.lib statically get a + // collector each: the library's frees objects the executable is still holding, because the + // executable's roots are not its to scan. Item 5ao - it produced silently wrong strings + // rather than a crash, and only where the value differed from whatever was allocated over + // it, which is why every other shared test passed. Statically linked programs keep the + // static collector; one binary already means one collector. + batFile << "set GC_LIB_PATH=" << TEST_GC_SHARED_LIBPATH << std::endl; // run everything inside a unique per-test working directory: the shared lib must keep its // real name (.dll) for `import './'` to resolve, but that name is not unique @@ -450,6 +512,7 @@ void createSharedMultiBatchFile(std::string tempOutputFileNameNoExt, std::vector batFile << "set WORKDIR=" << tempOutputFileNameNoExt << "_wd" << std::endl; batFile << "if exist %WORKDIR% rmdir /s /q %WORKDIR%" << std::endl; batFile << "mkdir %WORKDIR%" << std::endl; + batFile << "copy \"" << TEST_GC_SHARED_BINPATH << "\\gc.dll\" %WORKDIR% >nul" << std::endl; batFile << "cd %WORKDIR%" << std::endl; auto first = true; @@ -476,7 +539,7 @@ void createSharedMultiBatchFile(std::string tempOutputFileNameNoExt, std::vector } } - (first ? execBat : sharedBat) << "%TSLANGEXEPATH%\\tslang.exe --emit=obj " << tslang_opt << " " << (first ? "" : tslang_opt_ext) << " " << file << " -o=" << fileNameWithoutExt << ".obj" << std::endl; + (first ? execBat : sharedBat) << "%TSLANGEXEPATH%\\tslang.exe --emit=obj " << (first ? "--entry-point " : "") << tslang_opt << " " << (first ? "" : tslang_opt_ext) << " " << file << " -o=" << fileNameWithoutExt << ".obj" << std::endl; first = false; } @@ -571,7 +634,7 @@ void createSharedMultiBatchFile(std::string tempOutputFileNameNoExt, std::vector } } - (first ? execBat : sharedBat) << "$TSLANGEXEPATH/tslang --emit=obj " << tslang_opt << " " << (first ? "" : tslang_opt_ext) << " " << file << " -relocation-model=pic -o=" << fileNameWithoutExt << ".o" << std::endl; + (first ? execBat : sharedBat) << "$TSLANGEXEPATH/tslang --emit=obj " << (first ? "--entry-point " : "") << tslang_opt << " " << (first ? "" : tslang_opt_ext) << " " << file << " -relocation-model=pic -o=" << fileNameWithoutExt << ".o" << std::endl; first = false; } @@ -639,7 +702,7 @@ void testMutliFiles(std::vector &files) { if (jitRun) { - throw "not supported"; + throw std::runtime_error("-jit with several files needs -shared"); } createMultiCompileBatchFile(tempOutputFileNameNoExt, files); @@ -684,6 +747,17 @@ void readParams(int argc, char **argv, std::vector &files) fastMath = true; tslang_opt_ext += " --fast-math"; } + // `-mm=gc` is accepted as well as the two that change behaviour, and it is not a no-op: + // it names the default explicitly, which is what anyone comparing the three models types. + // It gets its own cached script like the others - the suffix is what keeps two runners + // with different flags from sharing one - so passing it costs a script and nothing else. + else if (std::string(argv[index]) == "-mm=gc" || std::string(argv[index]) == "-mm=rc" || + std::string(argv[index]) == "-mm=none") + { + memoryModel = std::string(argv[index]).substr(4); + tslang_opt_ext += " "; + tslang_opt_ext += argv[index]; + } else if (exists(argv[index])) { files.push_back(argv[index]); @@ -692,18 +766,18 @@ void readParams(int argc, char **argv, std::vector &files) { std::string msg = "unknown param or file does not exist: "; msg.append(argv[index]); - throw msg.c_str(); + throw std::runtime_error(msg); } } if (sharedLibCompileTime && !sharedLib) { - throw "-compile-time can be used with -shared"; + throw std::runtime_error("-compile-time can be used with -shared"); } if (sharedLibCompileTime && jitRun) { - throw "-compile-time can't be used with -jit"; + throw std::runtime_error("-compile-time can't be used with -jit"); } } @@ -724,7 +798,7 @@ int main(int argc, char **argv) } else { - throw "no file provided"; + throw std::runtime_error("no file provided"); } } catch (const std::exception &e) diff --git a/tslang/test/tester/tests/00add_promotes_both_operands.ts b/tslang/test/tester/tests/00add_promotes_both_operands.ts new file mode 100644 index 000000000..178523baa --- /dev/null +++ b/tslang/test/tester/tests/00add_promotes_both_operands.ts @@ -0,0 +1,66 @@ +// regression test: `+` must promote both operands to the wider type, like every other +// arithmetic operator, instead of coercing the right operand to the left operand's type. +// +// `+` is the one arithmetic operator that is also string concatenation, so it chose its +// result type from the left operand before looking at the right one, and then cast the +// right one to match. Where the right operand was the WIDER of the two, that cast +// truncated it -- and the truncation happened before the addition rather than after it, +// so the fraction (or the high bits) were gone by the time the two were added: +// +// 2 + 3.5 read 5 (3.5 truncated to 3, then 2 + 3) +// 1 + (-0.5) read 1 (-0.5 truncated to 0; truncating the SUM would give 0) +// true + 2.5 read 2 (2.5 dragged down to a boolean) +// i8(100) + i32(1000) read 76 (1100 wrapped into i8) +// +// `-`, `*` and `/` were all correct, because they share a promotion path that picks the +// wider of the two operand types. `+` now uses that same path whenever both operands are +// unambiguously numeric; every other shape (strings, `any`, unions, `[Symbol.toPrimitive]` +// objects) keeps the left-preferring behaviour that string concatenation depends on. + +function main() { + // the reported shape: integer on the left, float on the right + let i = 2; + let f = 3.5; + assert(i + f == 5.5, "i + f"); + assert(f + i == 5.5, "f + i"); + + // constant operands are not spared -- this was wrong at compile time too + assert(2 + 3.5 == 5.5, "literals"); + + // the fraction is discarded before the addition, not after: truncating the sum of + // 1 + (-0.5) would give 0, so a result of exactly 0.5 is what distinguishes them + assert(1 + (-0.5) == 0.5, "negative fraction"); + + // the other arithmetic operators, which were already right and must stay right + assert(i * f == 7.0, "i * f"); + assert(f - i == 1.5, "f - i"); + assert(i / f > 0.571 && i / f < 0.5715, "i / f"); + + // booleans widen to number first, so a boolean on the left cannot drag a float down + assert(true + 2.5 == 3.5, "true + float"); + assert(2.5 + true == 3.5, "float + true"); + assert(true + true == 2, "true + true"); + assert(true + 1 == 2, "true + int"); + assert(2 + true == 3, "int + true"); + + // narrowing between integer widths is the same bug without any float involved + let narrow: i8 = 100; + let wide: i32 = 1000; + assert(narrow + wide == 1100, "i8 + i32"); + assert(wide + narrow == 1100, "i32 + i8"); + + // integer + integer stays exact + assert(2 + 3 == 5, "int literals"); + let q = 7; + let r = 2; + assert(q + r == 9, "int vars"); + + // string concatenation is what the left-preferring rule exists for, and is unchanged + assert("fo" + 1 == "fo1", "string + int"); + assert(1 + "fo" == "1fo", "int + string"); + assert("n=" + 3.5 == "n=3.5", "string + float"); + let s = "x"; + assert(s + 2 == "x2", "string var + int"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00alloc_in_catch.ts b/tslang/test/tester/tests/00alloc_in_catch.ts new file mode 100644 index 000000000..f3f50a082 --- /dev/null +++ b/tslang/test/tester/tests/00alloc_in_catch.ts @@ -0,0 +1,140 @@ +// Allocating inside a catch or finally clause, on Win64, where the handler is a separate funclet +// and every call in it has to carry a `funclet` bundle naming its pad. +// +// The compiler used to ask for a zeroed block as `malloc` followed by a zero-fill, and LLVM +// recognises that pair and rewrites it into `calloc` - building the replacement call without +// carrying the original's operand bundles over. Inside a handler that dropped the bundle, and the +// funclet was then emitted as a bare prologue with no body and no catchret, so it faulted the +// moment it ran. Only `-mm=gc` was unaffected, and by accident: GCPass deletes the zero-fill, so +// the pattern the fold looks for never reached LLVM. That is why this needs the `-mm=none` and +// `-mm=rc` variants to be worth anything - under the default model it passes either way. +// +// It also only shows with optimisation on, and only when the allocation survives to be used +// inside the handler; a value the optimiser can drop takes the bug with it. +// +// See docs/reference-counting-evaluation.md section 9.28. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } + + get(): number { + return this.n; + } +} + +// the plainest shape: allocate in a catch clause and call through the result +function allocInCatch() { + let total = 0; + try { + throw 1; + } + catch (e: TypeOf<1>) { + const leaf = new Leaf(7); + total = leaf.get(); + } + + return total; +} + +// the same in a finally clause, which is a funclet of its own +function allocInFinally() { + let total = 0; + try { + total = 1; + } + finally { + const leaf = new Leaf(4); + total = total + leaf.get(); + } + + return total; +} + +// an array literal, so the allocation is not a class instance +function arrayInCatch() { + let total = 0; + try { + throw 1; + } + catch (e: TypeOf<1>) { + const xs = [3, 5, 9]; + total = xs[0] + xs[2]; + } + + return total; +} + +// a second allocation in the same handler, used after the first +function twoAllocsInCatch() { + let total = 0; + try { + throw 1; + } + catch (e: TypeOf<1>) { + const a = new Leaf(2); + const b = new Leaf(3); + total = a.get() * b.get(); + } + + return total; +} + +// a handler nested inside another handler. This one had to wait: a try/catch inside a catch +// crashed on its own, with nothing allocated in it, until 00nested_catch.ts's two fixes landed. +function allocInNestedCatch() { + let total = 0; + try { + throw 1; + } + catch (e: TypeOf<1>) { + try { + throw 2; + } + catch (inner: TypeOf<2>) { + const leaf = new Leaf(11); + total = leaf.get(); + } + } + + return total; +} + +// the handler allocates and then throws on, so the funclet is left by unwinding rather than by +// falling off its end +function allocInCatchThenThrow() { + try { + throw 1; + } + catch (e: TypeOf<1>) { + const leaf = new Leaf(6); + if (leaf.get() > 0) { + throw 2; + } + } + + return 0; +} + +function main() { + assert(allocInCatch() == 7, "a value allocated in a catch clause is usable there"); + assert(allocInFinally() == 5, "and in a finally clause"); + assert(arrayInCatch() == 12, "an array literal allocated in a catch clause is usable there"); + assert(twoAllocsInCatch() == 6, "two allocations in one handler both survive"); + assert(allocInNestedCatch() == 11, "a handler nested inside a handler allocates too"); + + let rethrown = false; + try { + allocInCatchThenThrow(); + } + catch (e: TypeOf<2>) { + rethrown = true; + } + + assert(rethrown, "a handler that allocates can still throw on"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00async_gc_threading.ts b/tslang/test/tester/tests/00async_gc_threading.ts new file mode 100644 index 000000000..21c9a181c --- /dev/null +++ b/tslang/test/tester/tests/00async_gc_threading.ts @@ -0,0 +1,46 @@ +// A coroutine is resumed on one of the async runtime's pool threads, and under `-mm=gc` its frame +// and everything its body builds come from the collector. Boehm allocates without taking a lock +// until something tells it there is more than one thread: `GC_need_to_lock` starts FALSE and its +// LOCK()/UNLOCK() expand to nothing. So a worker resuming a coroutine and the awaiting thread +// walked the same free lists at the same time with no lock at all, and the heap eventually said +// so - a fault, not a wrong answer. `GC_allow_register_threads` in the runtime's GC init sets that +// flag; the workers also register themselves, so a collection can suspend them and scan their +// stacks rather than free what only their registers still point at. +// +// `rc` and `none` were never affected: their coroutine frames go to the CRT heap, which locks +// whatever the program believes about itself. See docs/reference-counting-evaluation.md 9.57. +// +// This is a race, so it is a rate, not a certainty - both threads have to be inside the allocator +// together. Against the unfixed runtime this shape faults about 6 runs in 20 at `-O3` and 6 in 10 +// at `-O0`; smaller ones are much weaker (60k iterations: 2 in 20), which is why the count is what +// it is. Each iteration allocates on both sides, so the two are in the allocator at the same time +// rather than taking turns. + +class Node { + v: number; + + constructor(v: number) { + this.v = v; + } +} + +async function allocatesOnTheWorker(base: number): number { + let node = new Node(base); + + return node.v; +} + +function main() { + let total = 0.0; + for (let i = 0; i < 250000; i++) { + total = total + await allocatesOnTheWorker(1.0); + + // The awaiting side allocates too. + let mine = new Node(i); + total = total + mine.v * 0.0; + } + + assert(total == 250000.0, "every awaited body ran and gave its value back"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00async_result_types.ts b/tslang/test/tester/tests/00async_result_types.ts new file mode 100644 index 000000000..936c51a44 --- /dev/null +++ b/tslang/test/tester/tests/00async_result_types.ts @@ -0,0 +1,116 @@ +// What an `await` gives back used to travel through `!async.value`, and MLIR's async-to-LLVM +// conversion - which runs before this compiler's own types are lowered - converts that payload +// with an LLVM type converter that knows nothing about the TypeScript dialect. So awaiting +// anything whose type belongs to this dialect failed with "failed to legalize operation +// 'async.runtime.load'": `number`, `string`, `boolean`, a class, an array. Only the payloads that +// happened to be builtin MLIR types - `i32`, `i64`, `f32` - ever compiled, which is why every +// async test in this suite was written to return one of those. +// +// The result now travels through a slot in the awaiting function and the async value carries only +// a token, so there is no payload left to convert. See docs/reference-counting-evaluation.md +// section 9.56. +// +// Arguments were never the problem, despite how it first looked: `await twice(3.0)` failed and +// `await withDefault()` compiled because of what they RETURN, not what they take. Both directions +// are covered here. + +class Point { + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +async function aNumber(n: number): number { + return n * 2.0; +} + +async function aString(s: string): string { + return s + "!"; +} + +async function aBoolean(n: number): boolean { + return n > 0.0; +} + +async function aClass(x: number, y: number): Point { + return new Point(x, y); +} + +async function anArray(n: number): number[] { + return [n, n + 1.0, n + 2.0]; +} + +async function anI32(n: i32): i32 { + return n + n; +} + +async function nothing(): void { + // A void async function yields a token and no value at all - the case that always worked, + // kept so the token-only path stays covered from both sides. +} + +// Awaiting inside an async function, rather than from a plain one: this is a coroutine resumed +// from inside another coroutine, and it failed for the same reason (its result is a `number`). +async function awaitsInsideAsync(n: number): number { + const half = await aNumber(n); + + return half + 1.0; +} + +// The result has to survive the await, not just be produced by it. Under reference counting the +// value is built inside the awaited region, whose own temporaries are released as that region +// ends - so the slot has to own what it holds, or this reads freed memory. It read correctly at +// -O0 and garbage at -O3 before the slot took ownership, which is only ever a question of what +// reused the block first. +function churn(): number { + let total = 0.0; + for (let i = 0; i < 64; i++) { + let filler = new Point(999.0, 999.0); + total = total + filler.x * 0.0; + } + + return total; +} + +function classResultOutlivesTheAwait(): number { + const p = await aClass(3.0, 4.0); + churn(); + + return p.x + p.y; +} + +function stringResultOutlivesTheAwait(): number { + const s = await aString("ab"); + churn(); + + return s.length; +} + +function arrayResultOutlivesTheAwait(): number { + const a = await anArray(1.0); + churn(); + + return a[0] + a[1] + a[2]; +} + +function main() { + assert(await aNumber(2.0) == 4.0, "a `number` result"); + assert(await aString("ab") == "ab!", "a `string` result"); + assert(await aBoolean(1.0), "a `boolean` result"); + assert(await anI32(3) == 6, "an `i32` result still works"); + assert(await aClass(1.0, 2.0).y == 2.0, "a class result"); + assert(await anArray(1.0).length == 3, "an array result"); + await nothing(); + + assert(await awaitsInsideAsync(2.0) == 5.0, "awaiting inside an async function"); + + assert(classResultOutlivesTheAwait() == 7.0, "a class result survives the await"); + assert(stringResultOutlivesTheAwait() == 3, "a string result survives the await"); + assert(arrayResultOutlivesTheAwait() == 6.0, "an array result survives the await"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00break_continue_scope_exit.ts b/tslang/test/tester/tests/00break_continue_scope_exit.ts new file mode 100644 index 000000000..f6ba9f986 --- /dev/null +++ b/tslang/test/tester/tests/00break_continue_scope_exit.ts @@ -0,0 +1,124 @@ +// A `break` or `continue` owes every scope between itself and the loop it leaves - the +// disposals a `using` declared, and the references those scopes' locals took. It paid neither +// whenever it was written inside another block, which is where a `break` or `continue` is +// usually written: inside an `if`. +// +// The walk outwards stopped at the first scope that was not itself a loop, and `isLoop` reads +// true for every scope nested inside a loop, not just the loop's own body - so the very first +// step of the walk thought it had already arrived. Written directly in the loop body it +// happened to be right, which is why the shape below with no `if` around it always worked. +// +// Found by the ownership verifier (--verify-ownership) on its first run over the suite. +// 00owned_locals.ts already had the shape and asserted only counts, which a missed dispose +// does not change. See docs/reference-counting-evaluation.md section 9.18. +// +// The two scopes below that nest one `using` inside another - `bothScopes` and +// `labelledContinue` - were the verifier's other two findings, and stayed open far longer: +// the disposal in a scope's cleanup region used to unwind into the ENCLOSING scope's cleanup, +// stepping over the release written after it. Nothing here asserts that, because nothing can: +// the path is taken only when a disposal throws, and a throwing `[Symbol.dispose]()` does not +// work at all today. The verifier is the test, and it now runs over the whole corpus in ctest +// (test-ownership-verifier-*). See section 9.62. + +let disposed = 0; + +class Res { + [Symbol.dispose]() { + disposed = disposed + 1; + } +} + +// `continue` from inside an `if`: the using scope is the loop body, one level out +function continueFromIf() { + for (let i = 0; i < 3; i++) { + using r = new Res(); + if (i == 1) { + continue; + } + } +} + +// `break` likewise, reaching the loop body twice before leaving +function breakFromIf() { + for (let i = 0; i < 3; i++) { + using r = new Res(); + if (i == 1) { + break; + } + } +} + +// two levels of nesting between the `continue` and the loop +function continueFromNestedBlock() { + for (let i = 0; i < 3; i++) { + using r = new Res(); + { + if (i == 1) { + continue; + } + } + } +} + +// the control: written directly in the loop body, which always worked +function continueDirect() { + for (let i = 0; i < 3; i++) { + using r = new Res(); + continue; + } +} + +// a `using` in the intermediate scope too - both owe a dispose on the way out +function bothScopes() { + for (let i = 0; i < 2; i++) { + using outer = new Res(); + if (i == 0) { + using inner = new Res(); + continue; + } + } +} + +// the labelled form still stops at the loop it names and not before it +function labelledContinue() { + let rounds = 0; + outer: while (rounds < 2) { + rounds++; + using a = new Res(); + let j = 2; + while (j-- > 0) { + using b = new Res(); + continue outer; + } + } + + return rounds; +} + +function main() { + disposed = 0; + continueFromIf(); + assert(disposed == 3, "every iteration disposes, including the one that continues"); + + disposed = 0; + breakFromIf(); + assert(disposed == 2, "both iterations reached dispose, including the one that breaks"); + + disposed = 0; + continueFromNestedBlock(); + assert(disposed == 3, "two levels of nesting between the continue and the loop"); + + disposed = 0; + continueDirect(); + assert(disposed == 3, "the control: continue directly in the loop body"); + + disposed = 0; + bothScopes(); + assert(disposed == 3, "the intermediate scope's using disposes too"); + + disposed = 0; + assert(labelledContinue() == 2, "the labelled continue reaches its own loop"); + assert(disposed == 4, "and disposes both scopes it left, each round"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00capture_in_new_arguments.ts b/tslang/test/tester/tests/00capture_in_new_arguments.ts new file mode 100644 index 000000000..5dc5af2f3 --- /dev/null +++ b/tslang/test/tester/tests/00capture_in_new_arguments.ts @@ -0,0 +1,131 @@ +// A lambda captures what its body reads, and the body is read by the discovery pass - the dummy +// run that fills passResult->outerVariables. `new C(...)` took a shortcut there: it created the +// instance without resolving the constructor, and so never walked the constructor arguments at +// all. A variable read ONLY inside those arguments was therefore never registered as captured, +// and the real pass emitted the read against the enclosing function's own value from inside the +// lambda - "'ts.Load' op using value defined outside the region", because ts.Func is +// IsolatedFromAbove. It failed to compile under every memory model, so it is not a refcounting +// bug; it is recorded in docs/reference-counting-evaluation.md section 9.54 because it silently +// shaped the benchmarks written there. +// +// Every case reads the captured thing ONLY inside the `new` arguments. Reading it anywhere else +// in the same lambda - even once - registers the capture and hides the bug entirely, which is +// what `alsoReadOutsideTheNew` below is here to say. + +class Color { + r: number; + g: number; + + constructor(r: number, g: number) { + this.r = r; + this.g = g; + } +} + +class Boxed { + v: number; + + constructor(v: number) { + this.v = v; + } +} + +function capturedParam(a: number): number { + const make = (k: number) => { return new Boxed(a + k); }; + + return make(1.0).v; +} + +function capturedField(c: Color): number { + const make = (k: number) => { return new Boxed(c.r + k); }; + + return make(1.0).v; +} + +// A `let` the lambda only reads is still captured by value; the cell path is exercised by +// `capturedMutableLocal` below. +function capturedLocal(): number { + let base = 10.0; + const make = (k: number) => { return new Boxed(base + k); }; + + return make(1.0).v; +} + +// A variable the enclosing function writes after the lambda is built has to be captured by +// reference - the lambda reads the cell, not a copy - so this goes through a different capture +// shape than the three above. +function capturedMutableLocal(): number { + let base = 10.0; + const make = (k: number) => { return new Boxed(base + k); }; + base = 20.0; + + return make(1.0).v; +} + +// More than one argument, and more than one captured variable, so a partial walk of the arguments +// would still be caught. +function severalArgumentsAndCaptures(x: number, y: number): number { + const make = () => { return new Color(x, y); }; + const c = make(); + + return c.r * 10.0 + c.g; +} + +// The argument of the outer `new` is itself a `new` reading a captured variable, so the walk has +// to descend rather than just visit each argument's top node. +function nestedNew(a: number): number { + const make = () => { return new Boxed(new Boxed(a).v + 1.0); }; + + return make().v; +} + +// A lambda inside a lambda: the inner one reads a variable that belongs to the outermost +// function, which reaches it through the middle lambda's own capture. +function nestedLambdas(a: number): number { + const outer = () => { + const inner = () => { return new Boxed(a + 1.0); }; + return inner().v; + }; + + return outer(); +} + +class Owner { + scale: number; + + constructor(scale: number) { + this.scale = scale; + } + + // `this` captured by a lambda in a method, read only inside the `new` arguments. + build(k: number): number { + const make = () => { return new Boxed(this.scale * k); }; + + return make().v; + } +} + +// The control: the same capture read outside the `new` as well. This compiled all along, which is +// why the bug survived - it only bites when the arguments are the variable's only appearance. +function alsoReadOutsideTheNew(a: number): number { + const make = (k: number) => { + let seen = a; + return new Boxed(a + k + seen * 0.0); + }; + + return make(1.0).v; +} + +function main() { + assert(capturedParam(2.0) == 3.0, "a captured parameter read only in `new` arguments"); + assert(capturedField(new Color(2.0, 0.0)) == 3.0, "a captured object's field in `new` arguments"); + assert(capturedLocal() == 11.0, "a captured local in `new` arguments"); + assert(capturedMutableLocal() == 21.0, "a captured mutable local in `new` arguments"); + assert(severalArgumentsAndCaptures(3.0, 4.0) == 34.0, "several captures across several arguments"); + assert(nestedNew(2.0) == 3.0, "a `new` inside another `new`'s arguments"); + assert(nestedLambdas(2.0) == 3.0, "a lambda inside a lambda reaching the outer function"); + assert(new Owner(3.0).build(2.0) == 6.0, "`this` captured by a lambda in a method"); + assert(alsoReadOutsideTheNew(2.0) == 3.0, "the same capture read outside the `new` too"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00catch_value.ts b/tslang/test/tester/tests/00catch_value.ts new file mode 100644 index 000000000..643ac7415 --- /dev/null +++ b/tslang/test/tester/tests/00catch_value.ts @@ -0,0 +1,87 @@ +// Reading the value a `catch` clause binds. +// +// Section 9.29 found this broken, described it as "reads 0 rather than 2, but only in a module +// that throws just that one type", and drew the rule that no test should read a catch value at +// all. Section 9.65 re-measured it and neither half of that description survives. It does not +// read 0: the same binary run three times reads 134, 131, 184, which is uninitialised memory. +// And it is not about how many types the module throws - it is a back-end difference, correct +// ahead of time in every case tried and garbage under the JIT. Item 5am. +// +// This file passes in BOTH tiers, and that is worth stating because the first draft of it +// asserted otherwise and was wrong. Size is what decides which regime a module lands in: six +// catch values here is enough to be correct under the JIT as well, exactly as `00try_catch.ts` +// is. `00catch_value_minimal.ts` is the same feature cut to one clause, and it fails under the +// JIT; it is registered disabled there so the build says so. +// +// So this is the coverage section 9.29 declined to write. It is worth having on its own terms: +// `00try_catch.ts` reads catch values too, but nothing pinned the shapes below - three clauses +// of one type in a row, two payload types in one function, and a value read after its clause +// has ended. + +function caughtInt() { + let t = 0; + try { throw 2; } + catch (v: TypeOf<1>) { t = v; } + return t; +} + +function caughtNumber() { + let t = 0.0; + try { throw 2.5; } + catch (v: number) { t = v; } + return t; +} + +function caughtString() { + let s = ""; + try { throw "payload"; } + catch (v: string) { s = v; } + return s; +} + +// three of the same type in a row: section 9.65 read 425, -1765822016, 425 here under the JIT, +// so each one is checked rather than just the last +function threeInARow() { + let a = 0; + let b = 0; + let c = 0; + try { throw 7; } catch (v1: TypeOf<1>) { a = v1; } + try { throw 8; } catch (v2: TypeOf<1>) { b = v2; } + try { throw 9; } catch (v3: TypeOf<1>) { c = v3; } + return a * 100 + b * 10 + c; +} + +// two different payload types in one function, which is the shape 9.29 believed was the fix. +// Each is checked on its own: adding them would test integer-to-float promotion instead, and +// `t + u` here reads 5 rather than 5.5 - a separate question, and not one this file is about. +let twoTypesInt = 0; +let twoTypesNumber = 0.0; + +function twoTypes() { + try { throw 2; } + catch (v: TypeOf<1>) { twoTypesInt = v; } + try { throw 3.5; } + catch (w: number) { twoTypesNumber = w; } +} + +// the value survives being read after the clause, not just inside it +function readAfterTheClause() { + let t = 0; + try { throw 41; } + catch (v: TypeOf<1>) { t = v; } + t = t + 1; + return t; +} + +function main() { + assert(caughtInt() == 2, "an int catch binds the thrown value"); + assert(caughtNumber() == 2.5, "a number catch binds the thrown value"); + assert(caughtString() == "payload", "a string catch binds the thrown value"); + assert(threeInARow() == 789, "three catches of one type each bind their own value"); + twoTypes(); + assert(twoTypesInt == 2, "the int catch of a two-type function binds its own value"); + assert(twoTypesNumber == 3.5, "the number catch of a two-type function binds its own value"); + assert(readAfterTheClause() == 42, "the bound value outlives the clause"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00catch_value_minimal.ts b/tslang/test/tester/tests/00catch_value_minimal.ts new file mode 100644 index 000000000..f95b8665c --- /dev/null +++ b/tslang/test/tester/tests/00catch_value_minimal.ts @@ -0,0 +1,33 @@ +// The smallest program that reads a catch variable, and the one that found item 5am. +// +// It read uninitialised memory under the JIT - the same binary run three times gave 134, 131, +// 184 under `gc`, a stable 0 under `rc`, 73/135/15 under `none` - while being correct ahead of +// time in every model. Section 9.29 saw the same thing from the other side and read it as "only +// in a module that throws just that one type"; the real variable was size, and underneath that, +// which section RTDyld placed lowest. +// +// RTDyld resolves image-relative relocations against the LOWEST section load address, so a datum +// there has RVA 0 - and RVA 0 is the MSVC C++ EH encoding's "none" sentinel. When this file's +// `??_R0H@8` landed at the base, the clause read `dispType == 0`, which is `catch(...)`: it +// still caught, so nothing looked wrong, but a catch-all has no catch object and the value was +// never copied. Fixed by reserving one contiguous block laid out code-first, so the RVA-0 +// collision can only ever fall on code, where nothing reads 0 as "none". Sections 9.66 and 9.68. +// +// Kept minimal on purpose: `00catch_value.ts` covers the same feature more broadly and passed +// throughout, because six catch values is enough content to push the descriptor off the base. +// This file is the one that was small enough to break, so it is the one that guards the fix. + +let t = 0; + +function main() { + try { + throw 2; + } + catch (v: TypeOf<1>) { + t = v; + } + + assert(t == 2, "a catch clause binds the value that was thrown"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00conditional_owned_result.ts b/tslang/test/tester/tests/00conditional_owned_result.ts new file mode 100644 index 000000000..970ec0bcd --- /dev/null +++ b/tslang/test/tester/tests/00conditional_owned_result.ts @@ -0,0 +1,142 @@ +// regression test: a conditional expression whose branches disagree about ownership. +// +// `cond ? borrowed : f()` builds a `ts.If` with a result, and its two branches hand back +// references on different terms - one borrowed from wherever it already lives, one a fresh +1 +// that somebody has to take over. The receiver outside can only retain, so the +1 from the +// allocating branch was left with no owner and leaked every time that branch was taken. +// §9.30 could not reach it: the value's only user is `ts.Result`, a terminator, and releasing +// a value handed to a successor would free it while it is still live. +// +// It is not a corner of the language. In `raytrace.ts` a single line of this shape - +// let reflectedColor = (depth >= this.maxDepth) ? Color.grey : this.getReflectionColor(...) +// - held 18 of the 20 MB reference counting had left on the table, and rewriting just that line +// as `if`/`else` reclaimed it. See docs/reference-counting-evaluation.md section 9.73. +// +// The fix makes the branches agree before anything outside looks: the borrowing branch takes a +// reference of its own, so every branch yields +1 and the `ts.If` result carries a reference +// the way a call's result does. That direction matters - releasing the owned branch instead +// would be an over-release, because at no point inside the region has the receiver retained +// yet. So this test holds on to results from BOTH branches, allocates hard over anything +// wrongly freed, and only then reads them back: a freed block keeps its contents until +// something reuses it, which is what makes the churn load-bearing. + +class Box { + tag: string; + n: number; + constructor(tag: string, n: number) { + this.tag = tag; + this.n = n; + } +} + +const shared = new Box("shared", -1.0); +const other = new Box("other", -2.0); + +function make(n: number): Box { + return new Box("fresh", n); +} + +// the raytrace shape: borrowed on the left, freshly allocated on the right +function pick(useShared: boolean, n: number): Box { + return useShared ? shared : make(n); +} + +// the same with the branches the other way round +function pickReversed(useFresh: boolean, n: number): Box { + return useFresh ? make(n) : shared; +} + +// both branches allocate +function pickBoth(first: boolean, n: number): Box { + return first ? make(n) : make(n + 1.0); +} + +// neither branch allocates - must keep working exactly as before +function pickNeither(first: boolean): Box { + return first ? shared : other; +} + +// nested, so the inner conditional's result is itself a branch of the outer one +function pickNested(a: boolean, b: boolean, n: number): Box { + return a ? shared : (b ? make(n) : other); +} + +// a string rather than an object, since strings are their own owning type +function pickString(useLiteral: boolean, n: number): string { + return useLiteral ? "literal" : `fresh ${n}`; +} + +function main() { + // Hold results of every shape, taking both branches of each. + let kept: Box[] = []; + let keptStrings: string[] = []; + for (let i = 0; i < 12; i++) { + const even = i % 2 == 0; + kept.push(pick(even, i)); + kept.push(pickReversed(even, i)); + kept.push(pickBoth(even, i)); + kept.push(pickNeither(even)); + kept.push(pickNested(i % 3 == 0, even, i)); + keptStrings.push(pickString(even, i)); + } + + // Reuse anything that was released too early. + let churn = 0.0; + for (let i = 0; i < 20000; i++) { + churn = churn + pick(i % 2 == 0, i).n + pickBoth(i % 2 == 0, i).n; + churn = churn + pickString(i % 2 == 0, i).length; + } + + // A conditional whose result nothing receives at all: the discarded-temporary path. + for (let i = 0; i < 20000; i++) { + make(i); + pick(i % 2 == 0, i); + } + + let bad = 0; + for (let i = 0; i < 12; i++) { + const even = i % 2 == 0; + const at = i * 5; + + // pick: shared on even, fresh on odd + if (even) { + if (kept[at].tag != "shared" || kept[at].n != -1.0) bad = bad + 1; + } else { + if (kept[at].tag != "fresh" || kept[at].n != i) bad = bad + 1; + } + + // pickReversed: fresh on even, shared on odd + if (even) { + if (kept[at + 1].tag != "fresh" || kept[at + 1].n != i) bad = bad + 1; + } else { + if (kept[at + 1].tag != "shared" || kept[at + 1].n != -1.0) bad = bad + 1; + } + + // pickBoth: always fresh, n differs by branch + if (kept[at + 2].tag != "fresh") bad = bad + 1; + if (kept[at + 2].n != (even ? i : i + 1.0)) bad = bad + 1; + + // pickNeither: two different borrowed globals + if (kept[at + 3].tag != (even ? "shared" : "other")) bad = bad + 1; + + // pickNested + if (i % 3 == 0) { + if (kept[at + 4].tag != "shared") bad = bad + 1; + } else if (even) { + if (kept[at + 4].tag != "fresh" || kept[at + 4].n != i) bad = bad + 1; + } else { + if (kept[at + 4].tag != "other") bad = bad + 1; + } + + if (keptStrings[i] != (even ? "literal" : `fresh ${i}`)) bad = bad + 1; + } + + assert(bad == 0, "a conditional's result was freed while still referenced"); + + // the globals must still be intact after all of that + assert(shared.tag == "shared", "shared global survived"); + assert(other.tag == "other", "other global survived"); + assert(churn > 0.0, "churn"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00globals_entry_point.ts b/tslang/test/tester/tests/00globals_entry_point.ts new file mode 100644 index 000000000..49fc08a2c --- /dev/null +++ b/tslang/test/tester/tests/00globals_entry_point.ts @@ -0,0 +1,22 @@ +// A root made only of declarations and variable statements - no expression statement, no +// user-written main(). That still has to produce a runnable program: the initializers run +// from the global constructors, but without an entry point there is nothing for the JIT to +// call or the CRT to link against, and the run failed with "Symbols not found: [ main ]". +// +// Nothing here may be a *code* statement, or the file stops testing the case: an expression +// statement at the root makes an entry function get built for its own sake. So "done." is +// printed from a constructor, reached through the global-constructor path. +// +// The variables are exported to give them external linkage. Without it nothing reads them, +// and at --opt_level=3 LLVM drops both the globals and their constructors - side effect and +// all - leaving a run with no output that this test could not tell from a broken one. + +class Greeter { + constructor(public what: string) { + print(what); + } +} + +export const first = new Greeter("hello"); + +export let second = new Greeter("done."); diff --git a/tslang/test/tester/tests/00nested_catch.ts b/tslang/test/tester/tests/00nested_catch.ts new file mode 100644 index 000000000..fbb8e9ec9 --- /dev/null +++ b/tslang/test/tester/tests/00nested_catch.ts @@ -0,0 +1,226 @@ +// A `try`/`catch` written inside a `catch` clause. Two independent bugs made this crash - in +// every memory model, at every optimisation level, with nothing allocated in it - and the second +// one only became visible once the first was fixed. +// +// 1. The affine lowering finds a try's catch variable by walking the catches region, and the walk +// descended into a nested try. It picked up the *inner* clause's catch, which set the RTTI +// type filter on the outer try's landing pad from the wrong clause. +// 2. A catch clause can be ended twice over. The inner `throw` ends the enclosing catch ahead of +// itself, and the outer try then emits its own end-of-catch marker as well; the surplus one +// became the region's end instruction and survived into the emitted code, where an Itanium +// `__cxa_end_catch` has no Win64 counterpart and fails to link. +// +// A try nested in a try *body* or in a `finally` always worked - only the catch clause was +// affected, which is why nothing caught this. +// +// These check which clause runs and in what order, and deliberately never read a catch +// variable's value. That is not squeamishness about the fix: reading one is broken on its own, +// with no nesting involved - `try { throw 2 } catch (v: int) { t = v }` in a module that throws +// only that one type reads 0 rather than 2, and reads correctly again once the module throws +// other types elsewhere. `00try_catch.ts` passes for that second reason. A separate bug, and +// nothing here should be made to depend on it. +// +// See docs/reference-counting-evaluation.md section 9.29. + +type int = TypeOf<1>; + +// the plain shape: a catch inside a catch +function catchInCatch() { + let total = 0; + try { + throw 1; + } + catch (e: int) { + try { + throw 2; + } + catch (inner: int) { + total = 11; + } + } + + return total; +} + +// The outer try's landing pad must filter on its OWN clause's type. This is the direct test of +// bug 1: the two clauses take different types, so an outer pad carrying the inner clause's `int` +// filter would not catch the string at all and it would escape the function. +function outerFilterIsItsOwn() { + let seen = 0; + try { + throw "outer"; + } + catch (e: string) { + seen = 1; + try { + throw 2; + } + catch (inner: int) { + seen = seen + 10; + } + } + + return seen; +} + +// three deep, so the fix is not a special case for one level +function threeDeep() { + let total = 0; + try { + throw 1; + } + catch (a: int) { + try { + throw 2; + } + catch (b: int) { + try { + throw 3; + } + catch (c: int) { + total = 100; + } + + total = total + 10; + } + + total = total + 1; + } + + return total; +} + +// the inner try does not throw at all, so its catch never runs +function innerCatchNotTaken() { + let total = 0; + try { + throw 1; + } + catch (e: int) { + try { + total = 5; + } + catch (inner: int) { + total = 99; + } + } + + return total; +} + +// a nested try inside a catch, with a finally of its own +function nestedTryFinallyInCatch() { + let total = 0; + try { + throw 1; + } + catch (e: int) { + try { + throw 2; + } + catch (inner: int) { + total = 7; + } + finally { + total = total + 1; + } + } + + return total; +} + +// the whole thing inside a loop, so the regions are entered repeatedly +function catchInCatchInLoop() { + let total = 0; + for (let i = 0; i < 3; i++) { + try { + throw 1; + } + catch (e: int) { + try { + throw 2; + } + catch (inner: int) { + total = total + 2; + } + } + } + + return total; +} + +// the inner clause throws on, past the outer clause, out of the function +function nestedCatchThrowsOn() { + try { + throw 1; + } + catch (e: int) { + try { + throw 2; + } + catch (inner: int) { + throw 3; + } + } + + return 0; +} + +// the shapes that always worked, kept alongside so a fix here cannot quietly break them +function tryInTryBody() { + let total = 0; + try { + try { + throw 2; + } + catch (inner: int) { + total = 11; + } + } + catch (e: int) { + total = 1; + } + + return total; +} + +function tryInFinally() { + let total = 0; + try { + total = 1; + } + finally { + try { + throw 2; + } + catch (inner: int) { + total = total + 10; + } + } + + return total; +} + +function main() { + assert(catchInCatch() == 11, "a catch inside a catch runs"); + assert(outerFilterIsItsOwn() == 11, "the outer clause keeps its own type filter"); + assert(threeDeep() == 111, "three levels of catch nesting all run"); + assert(innerCatchNotTaken() == 5, "a nested try whose catch is not taken still runs its body"); + assert(nestedTryFinallyInCatch() == 8, "a nested try in a catch runs its own finally"); + assert(catchInCatchInLoop() == 6, "entering the nested regions repeatedly is fine"); + + let rethrown = false; + try { + nestedCatchThrowsOn(); + } + catch (e: int) { + rethrown = true; + } + + assert(rethrown, "a nested catch clause can throw past the outer one"); + + assert(tryInTryBody() == 11, "a try in a try body still works"); + assert(tryInFinally() == 11, "a try in a finally still works"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_any_boxing.ts b/tslang/test/tester/tests/00owned_any_boxing.ts new file mode 100644 index 000000000..bfaa1ec44 --- /dev/null +++ b/tslang/test/tester/tests/00owned_any_boxing.ts @@ -0,0 +1,165 @@ +// Boxing a value into `any` allocates a box and copies the value into it, and the box owns what +// it holds: when it dies it releases the payload through the type tag beside it. Nothing was +// taking that reference, so a boxed value that arrived carrying one - a call's result, or a +// closure and the box of captured variables it was built over - had that reference given back at +// the end of the block that boxed it, leaving the `any` pointing at freed memory. +// +// Every case below builds the `any` in one function and reads it in another, with `churn()` +// between, because within one block the release that causes it lands after the read and the case +// passes with the bug present. Reading through a freed-then-reused block is what fails. +// +// See docs/reference-counting-evaluation.md section 9.36. + +type reader = () => number; + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +// The general shape, and the one that says this is not about closures: a call's result carries a +// reference for its receiver, and the box is that receiver. +function makeName(): string { + return "na" + "me"; +} + +function boxCallResult(): any[] { + let out: any[] = []; + out.push(makeName()); + + return out; +} + +function callResultBoxedAsAny() { + let boxed = boxCallResult(); + churn(); + + return (boxed[0]).length; +} + +// The same for a closure, where what is freed is the capture box rather than the value itself, +// so the wrong answer comes back through the captured variable. +function boxClosure(k: number): any[] { + const kk = k; + let out: any[] = []; + out.push(qux); + + return out; + + function qux() { + return kk; + } +} + +function closureBoxedAsAny() { + let boxed = boxClosure(22); + churn(); + + return (boxed[0])(); +} + +// Not through an array: a single `any` field outlives its block just as an element does, and +// reaches the same boxing cast. +class AnyHolder { + item: any; + + constructor(item: any) { + this.item = item; + } +} + +function boxIntoField(k: number): AnyHolder { + const kk = k; + + return new AnyHolder(qux); + + function qux() { + return kk; + } +} + +function closureBoxedIntoField() { + let holder = boxIntoField(33); + churn(); + + return (holder.item)(); +} + +// A boxed value the frame also still holds: the box takes a reference of its own, so neither +// owner freeing is the other's problem, and the string outlives the shorter of the two. +function boxSharedString(): any[] { + let s = makeName(); + let out: any[] = []; + out.push(s); + + return out; +} + +function stringBoxedAndStillHeld() { + let boxed = boxSharedString(); + churn(); + + return (boxed[0]).length; +} + +// Two boxes over the same constant are two blocks. Boxing is an allocation, so two structurally +// identical boxing casts are not one value: CSE merged them, and the second assignment then stored +// a pointer to the block the first had already released. The read below is what that costs - the +// box is freed once at the assignment that replaced it and again at the end of the block, and in +// between something else is allocated over it. +// +// Only a constant reaches this shape: two reads of a runtime value are two `ts.Load`s, so the +// casts over them are not identical and CSE leaves them alone. A constant is CSE'd into one op +// first, which is what makes the casts over it identical. +// +// See docs/reference-counting-evaluation.md section 9.48. +function refillStrings() { + for (let i = 0; i < 64; i++) { + let filler: any = "xy"; + } +} + +function refillNumbers() { + for (let i = 0; i < 64; i++) { + let filler: any = 9.5; + } +} + +function sameStringBoxedTwice(): number { + let a: any = "abcd"; + a = 5; + a = "abcd"; + refillStrings(); + + return (a).length; +} + +function sameNumberBoxedTwice(): number { + let a: any = 4.5; + a = "x"; + a = 4.5; + refillNumbers(); + + return a; +} + +function main() { + assert(sameStringBoxedTwice() == 4, "boxing the same string twice makes two boxes"); + assert(sameNumberBoxedTwice() == 4.5, "boxing the same number twice makes two boxes"); + assert(callResultBoxedAsAny() == 4, "an `any` owns what a call handed it"); + assert(closureBoxedAsAny() == 22, "an `any` owns the closure boxed into it"); + assert(closureBoxedIntoField() == 33, "an `any` field owns what was boxed into it"); + assert(stringBoxedAndStillHeld() == 4, "boxing takes a reference of its own"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_array_ops.ts b/tslang/test/tester/tests/00owned_array_ops.ts new file mode 100644 index 000000000..a41161ab4 --- /dev/null +++ b/tslang/test/tester/tests/00owned_array_ops.ts @@ -0,0 +1,145 @@ +// push, unshift and splice put a value into an array's data block through their own ops rather +// than through an assignment, so - exactly like the array literal in 00owned_literals.ts - none +// of them took a reference to what they inserted, while the block releases every element it +// holds when it dies. +// +// Same recipe as the literal case, and the same teeth: one value reaching two slots that are +// both later overwritten. Each overwrite releases what its slot held, the first release is +// cancelled by the unconsumed birth reference, and the second takes the value past zero and +// frees it while a local still holds it. Checked case by case against the compiler as it stood: +// push, unshift, splice-insert and the pushed-twice case each returned 3 where 10 or 8 was due, +// and the spread case 6 where 13 was - the last only after being strengthened, see its comment. +// +// This also closes the spread form of an array literal that 00owned_literals.ts left open: +// `[...xs]` is built by a synthesised `for..of` calling push, so it inherits push's fix rather +// than needing one of its own. +// +// pop and shift are deliberately not paired with a release, and that is not an omission. The +// block simply stops holding the element - the size shrinks past the slot, so the release +// routine never reaches it - and the reference the block held transfers to the returned value. +// That leaves the result carrying the same "+1 nobody has consumed" every freshly produced value +// already carries, which comes out with the rest of the slack rather than one op at a time. The +// two cases below are run-path coverage for that transfer, not counting tests. +// +// 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 - +// the number of elements to release is only known inside the lowering. +// +// See docs/reference-counting-evaluation.md section 9.22. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +// two arrays push the same value, then both overwrite it +function pushSharedValue() { + let kept = new Leaf(7); + let a: Leaf[] = []; + let b: Leaf[] = []; + a.push(kept); + b.push(kept); + a[0] = new Leaf(1); + b[0] = new Leaf(2); + + return kept.n + a[0].n + b[0].n; +} + +// the same through unshift +function unshiftSharedValue() { + let kept = new Leaf(7); + let a: Leaf[] = []; + let b: Leaf[] = []; + a.unshift(kept); + b.unshift(kept); + a[0] = new Leaf(1); + b[0] = new Leaf(2); + + return kept.n + a[0].n + b[0].n; +} + +// and through the insert half of splice +function spliceInsertSharedValue() { + let kept = new Leaf(7); + let a = [new Leaf(0)]; + let b = [new Leaf(0)]; + a.splice(0, 0, kept); + b.splice(0, 0, kept); + a[0] = new Leaf(1); + b[0] = new Leaf(2); + + return kept.n + a[0].n + b[0].n; +} + +// the spread form of a literal, which builds itself out of push +// +// This one needs three overwrites rather than two, and finding that out is the reason each case +// here was run against the unfixed compiler separately instead of trusting the file as a whole. +// The source array is itself a literal, so it already retains under the previous slice, and that +// extra reference absorbs the second release on its own - with only `a` and `b` overwritten this +// case passed either way and would have been quietly worthless. Overwriting the source too spends +// the reference the literal legitimately holds, which puts the two spread copies back on the hook +// for theirs. +function spreadLiteralSharesValue() { + let kept = new Leaf(7); + let src = [kept]; + let a = [...src]; + let b = [...src]; + src[0] = new Leaf(3); + a[0] = new Leaf(1); + b[0] = new Leaf(2); + + return kept.n + a[0].n + b[0].n + src[0].n; +} + +// one array pushed twice with the same value, then both slots overwritten +function pushedTwiceIntoOneArray() { + let kept = new Leaf(5); + let arr: Leaf[] = []; + arr.push(kept); + arr.push(kept); + arr[0] = new Leaf(1); + arr[1] = new Leaf(2); + + return kept.n + arr[0].n + arr[1].n; +} + +// the block hands its reference to the caller rather than releasing it +function popTransfersToCaller() { + let arr = [new Leaf(3), new Leaf(4)]; + let last = arr.pop(); + + return last.n + arr[0].n; +} + +function shiftTransfersToCaller() { + let arr = [new Leaf(3), new Leaf(4)]; + let first = arr.shift(); + + return first.n + arr[0].n; +} + +// a single overwrite after a push, which the slack still covers either way +function pushThenOverwriteOnce() { + let arr: Leaf[] = []; + arr.push(new Leaf(5)); + arr[0] = new Leaf(6); + + return arr[0].n; +} + +function main() { + assert(pushSharedValue() == 10, "a pushed value in two arrays survives both overwrites"); + assert(unshiftSharedValue() == 10, "the same holds for unshift"); + assert(spliceInsertSharedValue() == 10, "and for the insert half of splice"); + assert(spreadLiteralSharesValue() == 13, "a spread literal inherits push's retain"); + assert(pushedTwiceIntoOneArray() == 8, "one array holding it twice survives both overwrites"); + assert(popTransfersToCaller() == 7, "pop hands its reference to the caller"); + assert(shiftTransfersToCaller() == 7, "shift hands its reference to the caller"); + assert(pushThenOverwriteOnce() == 6, "a single overwrite after a push still behaves"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_array_splice.ts b/tslang/test/tester/tests/00owned_array_splice.ts new file mode 100644 index 000000000..a0e511f2f --- /dev/null +++ b/tslang/test/tester/tests/00owned_array_splice.ts @@ -0,0 +1,109 @@ +// regression test: `splice` gives back the references held by the elements it removes. +// +// What `splice` deletes is memmoved over and then the array is realloc'd, so the references +// sitting in those slots were simply overwritten - dropped on the floor rather than released. +// §9.22 found this when it took the array-mutating ops and left it open deliberately: every +// other insertion point in this arc is in MLIRGen, where how many elements are involved is a +// compile-time matter, but here the count is a runtime value known only in the lowering. So +// this is the one release emitted from `LowerToLLVM` rather than from MLIRGen, which also puts +// it outside what the ownership verifier can see - hence this test. +// +// Measured: a loop splicing two of three boxed strings away held 16.4 MB under `-mm=rc` +// against 4.1 for the same program without the splice, with `none` at 28.7 so nothing was +// elided. It is 3.8 either way now. See docs/reference-counting-evaluation.md section 9.74. +// +// Releasing here is only sound because tslang's `splice` returns a COUNT, not the removed +// elements as JavaScript's does - so nothing outside can still be holding them by way of the +// return value. That was checked before the release was emitted, not assumed. +// +// The teeth are in the opposite direction from the leak. Releasing an element that something +// else still refers to frees live memory, so the cases below deliberately keep a second +// reference to spliced-out values and read it back after allocating hard over anything wrongly +// freed - a freed block keeps its contents until something reuses it. + +class Box { + tag: string; + constructor(tag: string) { this.tag = tag; } +} + +function main() { + // 1. an element spliced out of one array is still held by another, and must survive + let survivors: Box[] = []; + for (let i = 0; i < 40; i++) { + const keep = new Box(`kept-${i}`); + let victim: Box[] = []; + victim.push(new Box("doomed-a")); + victim.push(keep); + victim.push(new Box("doomed-b")); + survivors.push(keep); + victim.splice(0, 3); // removes all three, including `keep` + if (victim.length != 0) { + assert(false, "splice removed the wrong number of elements"); + } + } + + // 2. the same for strings, which are their own owning type + let keptStrings: string[] = []; + for (let i = 0; i < 40; i++) { + const s = `str-${i}`; + let arr: string[] = []; + arr.push("head"); + arr.push(s); + arr.push("tail"); + keptStrings.push(s); + arr.splice(1, 2); + if (arr.length != 1 || arr[0] != "head") { + assert(false, "splice left the wrong remainder"); + } + } + + // reuse anything released too early + let churn = 0; + for (let i = 0; i < 20000; i++) { + let t: Box[] = []; + t.push(new Box("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + t.push(new Box("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); + t.splice(0, 2); + churn = churn + t.length + 1; + } + + let bad = 0; + for (let i = 0; i < 40; i++) { + if (survivors[i].tag != `kept-${i}`) bad = bad + 1; + if (keptStrings[i] != `str-${i}`) bad = bad + 1; + } + assert(bad == 0, "splice released an element something else still referenced"); + + // 3. splice that inserts as well as removes + let mixed = ["a", "b", "c", "d"]; + mixed.splice(1, 2, "X", "Y", "Z"); + assert(mixed.length == 5, "insert+remove length"); + assert(mixed[0] == "a", "insert+remove [0]"); + assert(mixed[1] == "X", "insert+remove [1]"); + assert(mixed[2] == "Y", "insert+remove [2]"); + assert(mixed[3] == "Z", "insert+remove [3]"); + assert(mixed[4] == "d", "insert+remove [4]"); + + // 4. deleting more than is there - the release count is clamped to what exists, + // so this must not walk off the end + let short = ["p", "q"]; + short.splice(1, 10); + assert(short.length == 1, "over-long delete count"); + assert(short[0] == "p", "over-long delete kept the head"); + + // 5. deleting nothing + let untouched = ["m", "n"]; + untouched.splice(1, 0); + assert(untouched.length == 2, "zero delete count"); + assert(untouched[1] == "n", "zero delete kept the tail"); + + // 6. an element type that owns nothing must be unaffected + let nums = [1, 2, 3, 4]; + nums.splice(0, 2); + assert(nums.length == 2, "numeric splice length"); + assert(nums[0] == 3, "numeric splice [0]"); + assert(nums[1] == 4, "numeric splice [1]"); + + assert(churn > 0, "churn"); + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_async.ts b/tslang/test/tester/tests/00owned_async.ts new file mode 100644 index 000000000..42c7cd496 --- /dev/null +++ b/tslang/test/tester/tests/00owned_async.ts @@ -0,0 +1,83 @@ +// An async function's frame is asked for with `aligned_alloc` and given back with plain `free`. +// Under `-mm=gc` both halves are rewritten to the collector's own pair and the mismatch never +// shows; every other model goes to the CRT heap, where the two have to agree. Each case here +// completes at least one frame, and the loop completes many, so a heap that has been corrupted +// has somewhere to say so. +// +// The awaited functions here return `i32`, which is what this file was written around back when +// that was the only kind of result an `await` could carry (see section 9.56 - the payload type, +// not the argument, was what decided it). They are left as they are: this file is about the frame +// allocator, and `00async_result_types.ts` is where the result types are covered. + +let step = 3; + +async function one() { + return 1; +} + +async function fromGlobal() { + return step + step; +} + +async function withDefault(n = 7) { + return n; +} + +async function throughAnother() { + const inner = await fromGlobal(); + + return inner + 1; +} + +function awaitsOnce(): number { + return await one(); +} + +function awaitsAnArrow(): number { + const f = async () => 5; + + return await f(); +} + +function awaitsWithADefaultParameter(): number { + return await withDefault(); +} + +function awaitsInSequence(): number { + const a = await fromGlobal(); + step = 4; + const b = await fromGlobal(); + step = 5; + const c = await fromGlobal(); + + return a + b + c; +} + +function awaitsThroughAnother(): number { + step = 10; + + return await throughAnother(); +} + +// Many frames in a row: each one is allocated and released, so a mismatched pair has every +// chance to be noticed rather than surviving to the end of a short program. +function awaitsInALoop(): number { + let total = 0; + step = 1; + for (let i = 0; i < 64; i++) { + total = total + await fromGlobal(); + } + + return total; +} + +function main() { + assert(awaitsOnce() == 1, "await once"); + assert(awaitsAnArrow() == 5, "await an async arrow"); + assert(awaitsWithADefaultParameter() == 7, "await with a default parameter"); + assert(awaitsInSequence() == 24, "await in sequence"); + assert(awaitsThroughAnother() == 21, "await through another async function"); + assert(awaitsInALoop() == 128, "await in a loop"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_call_results.ts b/tslang/test/tester/tests/00owned_call_results.ts new file mode 100644 index 000000000..09327fdab --- /dev/null +++ b/tslang/test/tester/tests/00owned_call_results.ts @@ -0,0 +1,141 @@ +// Every function retains its result on the way out, so a receiver that retains it again is one +// owner above the truth and the value is never freed. Deciding which callees actually do that +// cannot be settled where MLIRGen builds the call, so it is settled afterwards, by a pass that +// looks at each function's returns instead of predicting them (section 9.27). +// +// These guard the direction that corrupts. Under-consuming leaks, which is invisible from inside +// the program; over-consuming takes the value below its true owner count and frees it while a +// receiver still points at it. Every case therefore calls `churn()` between the last release and +// the read, so a freed block is claimed by something else and a use-after-free shows up as a +// wrong answer rather than as the value that used to be there. +// +// Excluded from consumption, and each verified to still keep its retain: a callee with no body +// (`declare`), an indirect call through a function value, and a generator. Those are the shapes +// where consuming would free a reference nobody took. +// +// See docs/reference-counting-evaluation.md section 9.27. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +class Holder { + item: Leaf; +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Leaf(999); + } +} + +function make(v: number): Leaf { + let x = new Leaf(v); + return x; +} + +// a call result held by a local, read after an inner scope has released its own reference +function callResultOutlivesInnerScope() { + let out = new Leaf(0); + { + let tmp = make(5); + out = tmp; + } + + churn(); + return out.n; +} + +// the plainest shape: consume, then use +function callResultIntoLocal() { + let a = make(6); + churn(); + + return a.n; +} + +// two locals holding one call result +function callResultShared() { + let a = make(7); + let b = a; + churn(); + + return a.n + b.n; +} + +// a call result stored into a field, outliving the local that received it +function callResultIntoField() { + let h = new Holder(); + { + let tmp = make(8); + h.item = tmp; + } + + churn(); + return h.item.n; +} + +// a call result captured by an array literal, and one pushed +function callResultIntoArray() { + let arr = [make(3), make(4)]; + arr.push(make(2)); + churn(); + + return arr[0].n + arr[1].n + arr[2].n; +} + +// a call whose result is another call's result, so the transfer passes through two frames +function forward(v: number): Leaf { + return make(v); +} + +function callResultThroughTwoFrames() { + let a = forward(9); + churn(); + + return a.n; +} + +// a method returning a heap value +class Factory { + build(v: number): Leaf { + let x = new Leaf(v); + return x; + } +} + +function methodResult() { + let f = new Factory(); + let a = f.build(11); + churn(); + + return a.n; +} + +// an arrow function's result - a concise body returns without going through the return +// statement, so it needed a retain of its own before it could qualify at all +function arrowResult() { + const mk = (v: number): Leaf => new Leaf(v); + let a = mk(12); + churn(); + + return a.n; +} + +function main() { + assert(callResultOutlivesInnerScope() == 5, "a consumed call result outlives the local that took it"); + assert(callResultIntoLocal() == 6, "the plain case survives"); + assert(callResultShared() == 14, "two locals holding one call result both stay valid"); + assert(callResultIntoField() == 8, "a call result stored into a field outlives the receiver"); + assert(callResultIntoArray() == 9, "call results captured by an array stay valid"); + assert(callResultThroughTwoFrames() == 9, "a result forwarded through two frames survives"); + assert(methodResult() == 11, "a method's result is consumed like a function's"); + assert(arrowResult() == 12, "an arrow function's result too"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_closures.ts b/tslang/test/tester/tests/00owned_closures.ts new file mode 100644 index 000000000..c1c173252 --- /dev/null +++ b/tslang/test/tester/tests/00owned_closures.ts @@ -0,0 +1,342 @@ +// A closure's `this` is its capture box - heap-allocated, and named nowhere in the function +// type, so until section 9.33 nothing ever gave it back. It carries a type tag beside the +// pointer now, the same arrangement an interface got in section 9.31, and releases through the +// box's own routine. +// +// The tag is what separates the two kinds of function value that share one representation. A +// closure owns its capture box; a bound method's `this` is an object that belongs to whoever +// holds it, and `obj.m` must not take a reference to `obj` or hand one back. Only a closure +// built over captured variables gets a tag - `boundMethodKeepsItsObject` below is the case that +// fails loudly if that ever stops being true. +// +// What every case guards is the over-release direction. A capture box freed while a closure +// still refers to it reads whatever was allocated over it, so each case calls `churn()` between +// the point a release could happen and the point the captured values are read. Cases where the +// closure escapes - returned, stored, kept in an array - are the ones that would break if the +// box were given back at the end of the block that built it. +// +// The cases from `capturedObjectEscapes` down are about the other half: who owns the *cell* a +// captured variable lives in, which is section 9.34, and the ones from +// `capturedParameterEscapes` are about a cell that starts out holding something the frame does +// not own - a captured parameter - which is section 9.35. +// +// See docs/reference-counting-evaluation.md sections 9.33 to 9.35. + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +function apply(f: (v: Vec) => number, v: Vec): number { + return f(v); +} + +// The callee allocates before it calls, so a box released too early is not merely freed but +// overwritten before the closure reads it. +function applyAfterChurn(f: (v: Vec) => number, v: Vec): number { + churn(); + + return f(v); +} + +// the shape section 9.33 is about: a closure built, used as an argument, and never bound +function closureAsArgument() { + let base = new Vec(5); + + return apply((v: Vec) => v.x + base.x, base); +} + +// the same, with the box's release point crossed by an allocation before the call happens +function closureReadAfterCalleeAllocates() { + let base = new Vec(6); + + return applyAfterChurn((v: Vec) => v.x + base.x, base); +} + +// A closure that outlives the block that made it: the box must not be given back at the end of +// `makeAdder`, which is exactly where a discarded one would be. +// +// These two capture a number, so that what they check is the box's own lifetime and nothing +// else. What a captured *object* needs on top of that is section 9.34's question, and has cases +// of its own further down. +function makeAdder(k: number): (v: Vec) => number { + let bump = k + 1; + + return (v: Vec) => v.x + bump; +} + +function closureEscapesItsBlock() { + let add = makeAdder(9); + churn(); + + return add(new Vec(1)); +} + +// a closure kept in an array, called long after the block that built it is gone +let handlers: ((v: Vec) => number)[] = []; + +function registerHandler(k: number) { + let bump = k + 1; + handlers.push((v: Vec) => v.x + bump); +} + +function closureKeptInArray() { + registerHandler(20); + churn(); + + return handlers[0](new Vec(2)); +} + +// many closures built and dropped, each iteration's box given back at the end of that iteration +function closuresInALoop() { + let total = 0; + for (let i = 1; i <= 8; i++) { + let base = new Vec(i); + total = total + apply((v: Vec) => v.x + base.x, base); + } + + churn(); + + return total; +} + +// A bound method is not a closure: its `this` is the object, owned by whoever holds it. If a +// bound method ever took ownership of its receiver, this frees `holder` and reads churn's +// filler instead of 30. +class Holder { + v: Vec; + + constructor(v: Vec) { + this.v = v; + } + + read(): number { + return this.v.x; + } +} + +function boundMethodKeepsItsObject() { + let holder = new Holder(new Vec(30)); + let f = holder.read; + churn(); + + return f() + holder.v.x - holder.read(); +} + +// A variable a closure captures by reference does not live in the frame: its storage is a heap +// block of its own - a cell - so that the frame and the closure read and write the same +// variable. Section 9.34 is about who owns that cell, and the cases below are the four shapes +// that answer differently. +// +// Until then the frame released the *value* at scope exit and left the cell to leak, so a +// captured object carried out of its frame was read through a pointer to freed memory - the +// oldest bug in this file's neighbourhood, and older than any of the ownership work. +function makeObjectAdder(k: number): (v: Vec) => number { + let bump = new Vec(k); + + return (v: Vec) => v.x + bump.x; +} + +function capturedObjectEscapes() { + let add = makeObjectAdder(40); + churn(); + + return add(new Vec(2)); +} + +// Captured by value rather than through a cell: a `const` goes into the box as a copy of the +// reference, and a copy of a reference is a further owner of what it points at. +function makeReader(): () => number { + const held = new Vec(15); + + return () => held.x; +} + +function constCaptureIsOwned() { + let read = makeReader(); + churn(); + + return read(); +} + +// One variable, two closures. While both live the cell has three owners - the frame and each +// box - and nothing may free it until the last of them lets go. +function makePair(k: number): ((v: Vec) => number)[] { + let shared = new Vec(k); + + return [(v: Vec) => v.x + shared.x, (v: Vec) => v.x - shared.x]; +} + +function capturedCellSharedByTwoClosures() { + let fns = makePair(10); + churn(); + + return fns[0](new Vec(5)) + fns[1](new Vec(5)); +} + +// The other direction: the frame outlives every closure over the variable. Dropping the last +// box must not take the variable with it, which is what the frame's own reference to the cell +// is for. +function frameOutlivesTheClosure() { + let kept = new Vec(3); + { + let f = (v: Vec) => v.x + kept.x; + apply(f, new Vec(1)); + } + + churn(); + + return kept.x; +} + +// Assigning to a captured variable is still an ordinary assignment - the value in the cell is +// replaced, the cell is not - and the frame and the closure see the one variable. +function mutateThroughCapture() { + let cur = new Vec(1); + let step = () => { cur = new Vec(cur.x + 1); }; + + step(); + step(); + churn(); + + return cur.x; +} + +// A captured parameter lives in a cell too, and the difference is what the cell starts out +// holding: an argument, which belongs to the caller. So the cell takes a reference to it, which +// is what makes a cell the owner of its contents whoever put them there - section 9.35. +// +// The escape is two frames deep on purpose. `new Vec(50)` is a discarded temporary of +// `holdParamReader`, released at the end of that block, so if the cell had not taken a reference +// the value is gone before `capturedParameterEscapes` ever reads it. +function makeParamReader(v: Vec): () => number { + return () => v.x; +} + +function holdParamReader(): () => number { + return makeParamReader(new Vec(50)); +} + +function capturedParameterEscapes() { + let read = holdParamReader(); + churn(); + + return read(); +} + +// Assigning to a captured parameter from inside the closure gives up what the cell held - which +// is the caller's argument. The caller reads it afterwards, so if the cell were giving up a +// reference it never took, `held` is freed here while `mutateCapturedParameter` still holds it. +function bumpThroughCapture(v: Vec): number { + let step = () => { v = new Vec(v.x + 1); }; + + step(); + + return v.x; +} + +function mutateCapturedParameter() { + let held = new Vec(1); + let bumped = bumpThroughCapture(held); + churn(); + + return bumped + held.x; +} + +// The same assignment seen from the other side: written in the frame that declared the +// parameter, after the closure over it exists. The value stored has to be taken by the cell, or +// nothing holds it and the end of the block gives it back as a discarded temporary. +function reassignCapturedParameter(v: Vec): () => number { + let read = () => v.x; + v = new Vec(v.x + 10); + + return read; +} + +function capturedParameterReassignedInFrame() { + let read = reassignCapturedParameter(new Vec(5)); + churn(); + + return read(); +} + +// A captured declaration with no initializer. Its cell is a heap block, so before the first +// store it holds whatever the allocator last left there, and the scope exit releases the cell - +// contents included. +// +// An owned local can never be in this position: it is only owned when it has an initializer. A +// cell is listed whether it has one or not, which is the difference (section 9.36). +// +// What dirties the block is `writtenCell` running first: it allocates a cell of exactly this +// shape and frees it, leaving a string pointer behind that is itself already freed. The next +// cell is handed that block. A general `churn()` is not enough - it caught this about one run +// in ten, which is not a test - because the block has to be the right size *and* still hold +// something fatal to release. +function unwrittenCell(): number { + let unwritten: string; + let maybe = () => { if (1 > 1) unwritten = "no"; }; + maybe(); + + return 1; +} + +// The same declaration on the path that does write it: the store gives up what the cell held, +// which is the same unwritten contents read one step earlier. +function writtenCell(): number { + let written: string; + let set = () => { written = "ab" + "cd"; }; + set(); + + return written.length; +} + +function capturedNeverAssigned() { + let seen = 0; + for (let i = 0; i < 64; i++) { + writtenCell(); + seen = seen + unwrittenCell(); + } + + return seen; +} + +function capturedAssignedLater() { + let total = 0; + for (let i = 0; i < 64; i++) { + unwrittenCell(); + total = total + writtenCell(); + } + + return total; +} + +function main() { + assert(closureAsArgument() == 10, "a closure used as an argument survives the call"); + assert(closureReadAfterCalleeAllocates() == 12, "a capture box survives a callee that allocates first"); + assert(closureEscapesItsBlock() == 11, "a returned closure keeps its capture box"); + assert(closureKeptInArray() == 23, "a stored closure keeps its capture box"); + assert(closuresInALoop() == 72, "a loop's capture boxes do not disturb one another"); + assert(boundMethodKeepsItsObject() == 30, "a bound method does not take ownership of its object"); + assert(capturedObjectEscapes() == 42, "a captured object leaves the frame that made it"); + assert(constCaptureIsOwned() == 15, "a box owns what it captured by value"); + assert(capturedCellSharedByTwoClosures() == 10, "two closures share one captured variable"); + assert(frameOutlivesTheClosure() == 3, "a captured variable outlives the closures over it"); + assert(mutateThroughCapture() == 3, "the frame and the closure see one variable"); + assert(capturedParameterEscapes() == 50, "a cell owns the argument a captured parameter arrived with"); + assert(mutateCapturedParameter() == 3, "assigning through a captured parameter leaves the caller's value alone"); + assert(capturedParameterReassignedInFrame() == 15, "a captured parameter's cell takes what the frame stores in it"); + assert(capturedNeverAssigned() == 64, "a cell nothing wrote to is still released safely"); + assert(capturedAssignedLater() == 256, "a store into a cell nothing wrote to gives up nothing"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_construct_interface.ts b/tslang/test/tester/tests/00owned_construct_interface.ts new file mode 100644 index 000000000..16fb08f79 --- /dev/null +++ b/tslang/test/tester/tests/00owned_construct_interface.ts @@ -0,0 +1,175 @@ +// `new C(...)` where `C` is not a class but a value of a *constructor interface* - an interface +// whose only member is a `new` signature. The call goes through that interface's vtable slot, +// and the slot is filled by a method the compiler synthesises for the implementing class: it +// builds the instance, runs the constructor, casts the result to the interface the signature +// returns, and hands that back. +// +// That method is built op by op rather than from a `return` statement, so the retain a return +// performs (section 9.24) never ran for it. The instance arrives as an owned result nobody +// claimed, and section 9.30 releases such a value at the end of the block that produced it - +// which here is the block that returns the interface over it. Every caller of `new C(...)` was +// handed a block that had already been given back. +// +// As with section 9.31's tests, every case builds in one block and reads in another with +// `churn()` in between: that release sits at the END of the producing block, so a read in the +// same block still happens first and sees nothing wrong. +// +// The first four cases discriminate - each one corrupts the heap in three runs out of three +// with the retain removed. The last three are the plain class-to-interface cast with no +// constructor interface in it, which was already correct; they are kept as shape coverage. +// +// See docs/reference-counting-evaluation.md section 9.46. + +interface Shape { + area(): number; +} + +interface ShapeConstructor { + new(size: number): Shape; +} + +class Square implements ShapeConstructor { + size: number; + + constructor(size: number) { + this.size = size; + } + + area(): number { + return this.size * this.size; + } +} + +interface Named { + label(): string; +} + +interface NamedConstructor { + new(label: string): Named; +} + +class Tag implements NamedConstructor { + text: string; + + constructor(text: string) { + this.text = text; + } + + label(): string { + return this.text; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Square(999); + } +} + +// the shape 01class_new.ts has: a constructor interface bound once, then used to build +function buildSquare(size: number): Shape { + const Ctor: ShapeConstructor = new Square(0); + return new Ctor(size); +} + +function constructedThroughAConstructorInterface() { + let s = buildSquare(6); + churn(); + + return s.area(); +} + +// two instances from the same constructor value, so a shared over-release shows on both +function buildTwo(): number { + const Ctor: ShapeConstructor = new Square(0); + let a = new Ctor(3); + let b = new Ctor(4); + churn(); + + return a.area() + b.area(); +} + +function twoFromOneConstructor() { + return buildTwo(); +} + +// a field the instance owns in turn - freeing the instance frees the string with it +function buildTag(text: string): Named { + const Ctor: NamedConstructor = new Tag(""); + return new Ctor(text); +} + +function constructedInstanceKeepsItsString() { + let t = buildTag("kept"); + churn(); + + return t.label() == "kept" ? 41 : 0; +} + +// built in a loop and carried out of it, with every iteration's temporary given back +function buildLast(count: number): Shape { + const Ctor: ShapeConstructor = new Square(0); + let last = new Ctor(1); + for (let i = 2; i <= count; i++) { + last = new Ctor(i); + } + + return last; +} + +function lastOfALoopSurvives() { + let s = buildLast(9); + churn(); + + return s.area(); +} + +// the plain cast on its own, with no constructor interface in sight: a class instance made in +// one block, cast to an interface, and handed back +function asShape(size: number): Shape { + return new Square(size); +} + +function classCastToInterfaceOutlivesItsBlock() { + let s = asShape(7); + churn(); + + return s.area(); +} + +// the cast's result kept by somebody else, so the interface is the only thing holding on +let kept: Shape[] = []; + +function keepShape(size: number) { + kept.push(new Square(size)); +} + +function castInterfaceHeldInAnArray() { + keepShape(8); + churn(); + + return kept[0].area(); +} + +// a class local and an interface over it living side by side - one release each, and the +// instance has to survive until both are done +function classAndInterfaceTogether(): number { + let c = new Square(5); + let s: Shape = c; + churn(); + + return c.size + s.area(); +} + +function main() { + assert(constructedThroughAConstructorInterface() == 36, "an instance built through a constructor interface survives its maker"); + assert(twoFromOneConstructor() == 25, "two instances from one constructor value both survive"); + assert(constructedInstanceKeepsItsString() == 41, "an instance built through a constructor interface keeps what it owns"); + assert(lastOfALoopSurvives() == 81, "the instance a loop carries out is not released with the temporaries"); + assert(classCastToInterfaceOutlivesItsBlock() == 49, "a class cast to an interface outlives the block that made it"); + assert(castInterfaceHeldInAnArray() == 64, "an interface made from a class and kept elsewhere is not released"); + assert(classAndInterfaceTogether() == 30, "a class and an interface over it own the instance together"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_debug_info.ts b/tslang/test/tester/tests/00owned_debug_info.ts new file mode 100644 index 000000000..df7241ec6 --- /dev/null +++ b/tslang/test/tester/tests/00owned_debug_info.ts @@ -0,0 +1,54 @@ +// A reference-counted program built WITH debug info. Registered with `--di`, which no other +// test in this suite passes alongside `-mm=rc` - and that gap is why the bug below lasted from +// section 9.31 to section 9.69 with a one-line note and no test. +// +// It was not a wrong answer, it was no answer: `--di --opt_level=0 -mm=rc` failed to emit LLVM +// IR at all, for every reference-counted program, with "DISubprogram attached to more than one +// function". Every op in a generated ownership routine (`tsrel_`, `tsret_`, `__tslang_inc_ref`, +// `__tslang_dec_ref`, `__tslang_free_block`) is built with the location of whatever op triggered +// its generation, because a routine synthesised from a type has no source of its own - and under +// `--di` that location carries the enclosing user function's DISubprogram. The routines are +// rc-only, so `gc` and `none` never saw it. +// +// The shapes below are chosen to make the compiler generate those routines: a string local, an +// owning field, an array, a closure over a captured variable, and a class instance. Each needs a +// release routine, and between them they reach the retain routines and all three `__tslang_*` +// helpers. What is asserted is ordinary behaviour - the point of the test is that it compiles. + +class Holder { + name: string; + constructor(name: string) { this.name = name; } +} + +function ownsAString() { + let s = "value"; + return s.length; +} + +function ownsAField() { + let h = new Holder("field"); + return h.name.length; +} + +function ownsAnArray() { + let a = ["one", "two"]; + a.push("three"); + return a.length; +} + +function ownsACapture() { + let seen = 0; + let bump = () => { seen = seen + 1; }; + bump(); + bump(); + return seen; +} + +function main() { + assert(ownsAString() == 5, "a string local"); + assert(ownsAField() == 5, "a field that owns a string"); + assert(ownsAnArray() == 3, "an array that owns its elements"); + assert(ownsACapture() == 2, "a closure over a captured variable"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_delete.ts b/tslang/test/tester/tests/00owned_delete.ts new file mode 100644 index 000000000..2f1532d5e --- /dev/null +++ b/tslang/test/tester/tests/00owned_delete.ts @@ -0,0 +1,121 @@ +// Under reference counting `delete` gives up a reference rather than freeing outright. The +// reference it gives up is the one the expression named, so whatever else would have released +// that same reference - the end-of-block release of a temporary nobody claimed, or the storage +// a variable lives in - has to stop doing so, or the block is let go of twice. +// +// `deleteFromAStaticMethod` is the case that carries this: with the fix reverted it faults three +// runs in three, and the others double-free without anything noticing. They are here as shape +// coverage - a variable with storage, a loop, an object owning a string, a delete on one branch +// of two - not because any of them discriminates today. +// +// What `delete` means differs by model, so a second reference to a deleted object is not +// something a test shared by all three can say anything about: under `gc` and `none` the block +// is freed outright and any other reference dangles, while under `rc` it is one owner going +// away. Every case below holds exactly one. + +let glb = 0; + +function churn(): number { + let n = 0; + for (let i = 0; i < 400; i++) { + const s = "filler " + i; + n = n + s.length; + } + + return n; +} + +class Cell { + value: number = 0; + label: string = ""; +} + +// No initialisers, so no constructor runs and nothing takes the reference `new` hands back on +// the way in. That is the shape `00class_static.ts` is built from, and the one where the +// end-of-block release of an unclaimed temporary is what runs the second time. +class Bare { + value: number; +} + +function deleteALet() { + let c = new Cell(); + c.value = 8; + print(c.value); + delete c; + churn(); + assert(glb == 0, "a variable with storage, deleted"); +} + +// The shape `00class_static.ts` is built from, and the one that faults rather than merely +// double-freeing quietly: a static method that builds an instance, prints twice - each print of +// a number builds a string the block also releases - and deletes it. +class Static { + static count: number; + pin: number; + + static run() { + const s = new Static(); + s.pin = 10; + print(s.pin); + + Static.count = 20; + print(Static.count); + + delete s; + } +} + +function deleteFromAStaticMethod() { + Static.run(); + churn(); + assert(Static.count == 20, "the static method returned with its heap intact"); +} + +function deleteManyInALoop() { + let seen = 0; + for (let i = 0; i < 200; i++) { + const b = new Bare(); + b.value = i; + seen = seen + b.value; + delete b; + } + + churn(); + assert(seen == 19900, "two hundred allocations, each deleted"); +} + +function deleteAnObjectHoldingAString() { + const c = new Cell(); + c.label = "held" + glb; + assert(c.label == "held0", "the field holds what was built"); + delete c; + churn(); + assert(glb == 0, "and the string went with it"); +} + +function deleteInsideABranch() { + let total = 0; + for (let i = 0; i < 100; i++) { + const c = new Cell(); + c.value = i; + if (i % 2 == 0) { + total = total + c.value; + delete c; + } else { + total = total + c.value; + } + } + + churn(); + assert(total == 4950, "deleted on one path and not the other"); +} + +function main() { + deleteFromAStaticMethod(); + deleteALet(); + deleteManyInALoop(); + deleteAnObjectHoldingAString(); + deleteInsideABranch(); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_early_return.ts b/tslang/test/tester/tests/00owned_early_return.ts new file mode 100644 index 000000000..de7eef6f5 --- /dev/null +++ b/tslang/test/tester/tests/00owned_early_return.ts @@ -0,0 +1,252 @@ +// A value nothing receives is released at the end of the block that produced it (section 9.30). +// The end of the block is not the only way out of it: a `return` written inside an `if` - which is +// where returns are usually written - leaves from a nested region and never reaches that release, +// so the value was simply lost on that path. `raytrace.ts` spent most of what it held on exactly +// this shape, a ray built for a shadow test in a function that returns early when the light is +// blocked: 43.5 MB against `gc`'s 2.8 before, 6.2 MB after. See section 9.60. +// +// A leak is not something a test can assert, so these cases are here for the other direction: the +// release now emitted before each early return must not be a SECOND one. Every case builds over +// the memory it might have freed and then reads what it built, so a value given back twice is a +// wrong answer or a fault rather than nothing at all. +// +// `break` and `continue` are the cases that say why only returns are covered. Neither leaves the +// block holding the temporary - control comes back and runs the end-of-block release - so +// releasing at one as well would give the same reference back twice. + +class Holder { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +function make(x: number): Holder { + return new Holder(x); +} + +// Allocate over whatever has just been freed, so a double release shows as a wrong answer. +function churn(): number { + let total = 0.0; + for (let i = 0; i < 32; i++) { + let filler = new Holder(777.0); + total = total + filler.x * 0.0; + } + + return total; +} + +// The shape itself: a temporary nothing receives, then a return out of an `if`. +function earlyReturnFromIf(takeIt: boolean): number { + make(1.0); + if (takeIt) { + return 10.0; + } + + return 20.0; +} + +// Two of them, and a return under each. +function twoTemporariesTwoExits(takeIt: boolean): number { + make(2.0); + make(3.0); + if (takeIt) { + return 11.0; + } + + return 21.0; +} + +// The return is deeper than one block down. +function earlyReturnFromNestedIf(a: boolean, b: boolean): number { + make(4.0); + if (a) { + if (b) { + return 12.0; + } + } + + return 22.0; +} + +// The return is inside a loop, which is inside the block that owns the temporary. +function earlyReturnFromLoop(stopAt: number): number { + make(5.0); + for (let i = 0; i < 8; i++) { + if (i == stopAt) { + return 13.0; + } + } + + return 23.0; +} + +// A `break` does not leave this block - control reaches the end of it either way - so the value is +// given back exactly once. Releasing at the break as well would free it while the end-of-block +// release still had it, and `churn` below would then hand the same block out twice. +function breakDoesNotLeaveTheBlock(stopAt: number): number { + let kept = make(6.0); + for (let i = 0; i < 8; i++) { + if (i == stopAt) { + break; + } + } + + churn(); + + return kept.x; +} + +function continueDoesNotLeaveTheBlock(skipAt: number): number { + let kept = make(7.0); + let seen = 0.0; + for (let i = 0; i < 8; i++) { + if (i == skipAt) { + continue; + } + + seen = seen + 1.0; + } + + churn(); + + return kept.x + seen; +} + +// The value the early return hands back is the caller's, not a temporary: the return retains it, +// so nothing here may give it away. Read after a churn, in another function, for the same reason. +function returnsWhatItBuilt(takeIt: boolean): Holder { + let h = make(8.0); + if (takeIt) { + return h; + } + + return make(9.0); +} + +function readAfterEarlyReturn(): number { + let h = returnsWhatItBuilt(true); + churn(); + + return h.x; +} + +function readAfterTailReturn(): number { + let h = returnsWhatItBuilt(false); + churn(); + + return h.x; +} + +// A `break` leaves the loop body for good, so an iteration that ends in one skips the release at +// the end of that body: the temporary built by the iteration that breaks needs one at the jump. +// Whether a jump leaves the block holding the temporary is the whole question - see the two cases +// below it, where it does not - and the answer decides between a leak and a double release. +function temporaryInALoopBodyWithBreak(stopAt: number): number { + let seen = 0.0; + for (let i = 0; i < 8; i++) { + make(30.0); + seen = seen + 1.0; + if (i == stopAt) { + break; + } + } + + churn(); + + return seen; +} + +function temporaryInALoopBodyWithContinue(skipAt: number): number { + let seen = 0.0; + for (let i = 0; i < 8; i++) { + make(31.0); + if (i == skipAt) { + continue; + } + + seen = seen + 1.0; + } + + churn(); + + return seen; +} + +// A labelled `break` leaves both loops, so the inner body's temporary is left behind by it too. +function temporaryLeftByALabelledBreak(): number { + let seen = 0.0; + outer: for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + make(32.0); + seen = seen + 1.0; + if (j == 1) { + break outer; + } + } + } + + churn(); + + return seen; +} + +// The other direction, and the one a release would break: the temporary belongs to THIS block, +// and the `break` below it leaves only the loop. Control comes back here and the end-of-block +// release runs, so a release at the jump as well would give the same reference back twice. +function breakBelowTheTemporary(stopAt: number): number { + make(33.0); + let seen = 0.0; + for (let i = 0; i < 8; i++) { + seen = seen + 1.0; + if (i == stopAt) { + break; + } + } + + churn(); + + return seen; +} + +function continueBelowTheTemporary(skipAt: number): number { + make(34.0); + let seen = 0.0; + for (let i = 0; i < 8; i++) { + if (i == skipAt) { + continue; + } + + seen = seen + 1.0; + } + + churn(); + + return seen; +} + +function main() { + assert(earlyReturnFromIf(true) == 10.0, "early return out of an `if`"); + assert(earlyReturnFromIf(false) == 20.0, "the same function's tail return"); + assert(twoTemporariesTwoExits(true) == 11.0, "two temporaries, early exit"); + assert(twoTemporariesTwoExits(false) == 21.0, "two temporaries, tail exit"); + assert(earlyReturnFromNestedIf(true, true) == 12.0, "return from two blocks down"); + assert(earlyReturnFromNestedIf(true, false) == 22.0, "the path that falls through"); + assert(earlyReturnFromLoop(3) == 13.0, "return out of a loop"); + assert(earlyReturnFromLoop(99) == 23.0, "the loop that finishes"); + + assert(breakDoesNotLeaveTheBlock(3) == 6.0, "`break` leaves the loop, not the block"); + assert(continueDoesNotLeaveTheBlock(3) == 14.0, "`continue` leaves the iteration, not the block"); + + assert(readAfterEarlyReturn() == 8.0, "what an early return hands back survives"); + assert(readAfterTailReturn() == 9.0, "what a tail return hands back survives"); + + assert(temporaryInALoopBodyWithBreak(3) == 4.0, "a temporary left behind by `break`"); + assert(temporaryInALoopBodyWithContinue(3) == 7.0, "a temporary left behind by `continue`"); + assert(temporaryLeftByALabelledBreak() == 2.0, "a labelled `break` leaves both loops"); + assert(breakBelowTheTemporary(3) == 4.0, "a `break` below the temporary leaves only the loop"); + assert(continueBelowTheTemporary(3) == 7.0, "a `continue` below the temporary leaves only the iteration"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_elements.ts b/tslang/test/tester/tests/00owned_elements.ts new file mode 100644 index 000000000..1dd8c0660 --- /dev/null +++ b/tslang/test/tester/tests/00owned_elements.ts @@ -0,0 +1,111 @@ +// Overwriting an element of a `T[]` hands ownership over, exactly the way overwriting a field +// does. 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 before this, `arr[i] = x` was a bare `ts.Store` that gave the data block a +// reference nobody took, and dropped the outgoing one without releasing it. +// +// Unlike 00owned_fields.ts, these DO have teeth on the counting already, and finding out why +// was the point of running the check rather than assuming the answer. The same experiment - +// swap the store to release-before-retain, the classic way to free the value you are about to +// store back - leaves every field case passing but makes selfAssignElement below read back 0 +// instead of 5. +// +// The asymmetry is not about elements. An array literal stores its elements without retaining +// them (`ts.CreateArray` emits no retain), so an element seeded by a literal holds only its +// birth reference and a release-first drops it straight to zero; a field filled through the +// assignment path holds birth + field and survives a stray release. That makes the literal a +// latent over-release in its own right - the array's release routine gives up a reference the +// literal never took - currently cancelled out exactly by the unconsumed birth reference. +// See section 9.20; fixing literal construction is the next slice. +// +// Not covered here: push, unshift and splice put a value into the 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 belong together in a later +// slice. +// +// See docs/reference-counting-evaluation.md section 9.20. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +// the plain case: overwrite an element repeatedly, read it back +function overwriteElement() { + let arr = [new Leaf(1), new Leaf(2)]; + arr[0] = new Leaf(3); + arr[0] = new Leaf(4); + return arr[0].n + arr[1].n; +} + +// something else still holds the value the element is about to drop +function aliasOutlivesElement() { + let kept = new Leaf(7); + let arr = [kept, new Leaf(1)]; + arr[0] = new Leaf(8); + + // `kept` must still be readable: the element gave up its reference, not the last one + return kept.n + arr[0].n; +} + +// the self-assignment case retain-before-release exists for +function selfAssignElement() { + let arr = [new Leaf(5)]; + arr[0] = arr[0]; + return arr[0].n; +} + +// one leaf held by two arrays, then one of them overwrites +function sharedBetweenArrays() { + let leaf = new Leaf(4); + let a = [leaf]; + let b = [leaf]; + a[0] = new Leaf(9); + + return b[0].n + leaf.n + a[0].n; +} + +// an element assigned from another array's element +function elementFromElement() { + let a = [new Leaf(6)]; + let b = [new Leaf(1)]; + b[0] = a[0]; + a[0] = new Leaf(2); + + return b[0].n + a[0].n; +} + +// one value reaching two slots of the same array, then the slot it came from is overwritten +function aliasedAcrossElements() { + let holder = new Leaf(3); + let arr = [holder, new Leaf(1)]; + arr[1] = arr[0]; + arr[0] = new Leaf(10); + + return arr[0].n + arr[1].n + holder.n; +} + +// writing through an element inside a loop, where the same slot is overwritten many times +function overwriteInLoop() { + let arr = [new Leaf(0)]; + for (let i = 1; i <= 5; i++) { + arr[0] = new Leaf(i); + } + + return arr[0].n; +} + +function main() { + assert(overwriteElement() == 6, "the last value stored is the one read back"); + assert(aliasOutlivesElement() == 15, "a reference kept elsewhere outlives the element's"); + assert(selfAssignElement() == 5, "self-assignment does not free the value it stores back"); + assert(sharedBetweenArrays() == 17, "one array overwriting does not disturb the other"); + assert(elementFromElement() == 8, "an element assigned from another keeps both alive"); + assert(aliasedAcrossElements() == 16, "an aliased element survives the slot it came from"); + assert(overwriteInLoop() == 5, "repeated overwriting of one slot keeps the last value"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_fields.ts b/tslang/test/tester/tests/00owned_fields.ts new file mode 100644 index 000000000..97ca3a59d --- /dev/null +++ b/tslang/test/tester/tests/00owned_fields.ts @@ -0,0 +1,95 @@ +// Overwriting a field of a class or object instance hands ownership over, the same way +// overwriting an owned local does: the incoming value gains an owner and the outgoing one loses +// one. Before this, a field store was a bare `ts.Store` - the instance's release routine +// released whatever the field held (releaseFields has always done that), but nothing ever took +// the reference it was releasing, and nothing gave up the reference an overwritten value still +// held. +// +// These now guard the counting, which they did not when they were written. At that point a +// freshly allocated value's birth reference was still unconsumed, so every count sat one above +// the truth, nothing could reach zero on a live value, and swapping the store to +// release-before-retain - the classic way to free the value you are about to store back - left +// every case below passing. The header said so, and asked for the experiment to be re-run once +// the slack went. +// +// It has been. Allocations are born unowned (section 9.24) and `new C()` is consumed rather than +// retained again (section 9.25), so `selfAssign` below now reads through a freed value under that +// swap and the test fails as it should. They were written as aliasing cases for exactly this: +// each keeps its own reference to a value, overwrites the field that also held it, and then reads +// through the reference it kept. +// +// See docs/reference-counting-evaluation.md section 9.19. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +class Holder { + item: Leaf; +} + +// the plain case: overwrite a field repeatedly, read it back +function overwriteField() { + let h = new Holder(); + h.item = new Leaf(1); + h.item = new Leaf(2); + h.item = new Leaf(3); + return h.item.n; +} + +// something else still holds the value the field is about to drop +function aliasOutlivesField() { + let h = new Holder(); + let kept = new Leaf(7); + h.item = kept; + h.item = new Leaf(8); + + // `kept` must still be readable: the field gave up its reference, not the last one + return kept.n + h.item.n; +} + +// the self-assignment case retain-before-release exists for: releasing first could drop the +// last reference and free the value about to be stored back +function selfAssign() { + let h = new Holder(); + h.item = new Leaf(5); + h.item = h.item; + return h.item.n; +} + +// two holders pointing at one leaf, then one of them overwrites +function sharedBetweenHolders() { + let leaf = new Leaf(4); + let a = new Holder(); + let b = new Holder(); + a.item = leaf; + b.item = leaf; + a.item = new Leaf(9); + + return b.item.n + leaf.n + a.item.n; +} + +// a field assigned from another field +function fieldFromField() { + let a = new Holder(); + let b = new Holder(); + a.item = new Leaf(6); + b.item = a.item; + a.item = new Leaf(2); + + return b.item.n + a.item.n; +} + +function main() { + assert(overwriteField() == 3, "the last value stored is the one read back"); + assert(aliasOutlivesField() == 15, "a reference kept elsewhere outlives the field's"); + assert(selfAssign() == 5, "self-assignment does not free the value it stores back"); + assert(sharedBetweenHolders() == 17, "one holder overwriting does not disturb the other"); + assert(fieldFromField() == 8, "a field assigned from another field keeps both alive"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_generator_locals.ts b/tslang/test/tester/tests/00owned_generator_locals.ts new file mode 100644 index 000000000..ecf87ee00 --- /dev/null +++ b/tslang/test/tester/tests/00owned_generator_locals.ts @@ -0,0 +1,104 @@ +// A generator's locals cannot live in its frame - the state machine has to resume - so each one +// becomes a field of a heap state object, and that object's release routine gives back every +// field that owns memory. Nothing ever took those references: the frame declines ownership of +// these locals for exactly the reason the object has it, and the store into the object was not +// treated as a field gaining a value. +// +// The other half is what a capture box holds. A read-write capture puts the address of the +// variable's cell in the box; every other capture puts a copy of the value there, and that copy +// is released by the box like any other owning field. Only the cell was ever retained, so the +// value behind a by-value capture - the source array of a generator, the string a closure reads - +// was freed when the box died, which is at the end of the function that built it. +// +// Both halves are needed together, and in this order: the local retain turns the second bug from +// a silent read of a freed block into a write of a refcount into one. +// +// See docs/reference-counting-evaluation.md section 9.50. + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let words = ["zz", "yy", "xx"]; + let joined = "z" + "z"; + } +} + +// The escaping generator: `filter` is a synthesised generator, and the array it walks is captured +// by value into a box the maker owned and released on the way out. +function makeFiltered(names: string[]) { + return names.filter(x => x.length > 1); +} + +function generatorOutlivesItsMaker(): number { + let g = makeFiltered(["ab", "c", "de"]); + churn(); + + let total = 0; + for (const s of g) total += s.length; + + return total; +} + +// The same generator consumed through a spread, which is how `00spread.ts` fails: the `for...of` +// the synthesised generator runs stores the source array into a generator local. This is the case +// with teeth, and it needs all three of `--opt --opt_level=3`, the interpolated string built +// inside the callee, and printing the result - the first because the double free is only reachable +// once the optimiser proves the two pointers equal, the other two because a block that is freed +// and never reused reads back exactly as it did before. +function sum3(x = 0, y = 0, z = 0) { + print(`Values ${x}, ${y}, ${z}`); + + return x + y + z; +} + +function spreadOfFilteredArray(): number { + const evens = [1, 2, 3, 4, 5, 6].filter(x => x % 2 == 0); + + return sum3(...evens); +} + +// A generator's own local, holding a freshly built string across a yield - so the state object is +// what carries it from one resumption to the next. +function* decorated(parts: string[]) { + for (const p of parts) { + let open = "<" + p; + yield open + ">"; + } +} + +function generatorLocalHeldAcrossYield(): number { + let total = 0; + for (const s of decorated(["a", "bc"])) total += s.length; + + return total; +} + +// Not generators at all: a closure over a `const` string is a by-value capture too, and the same +// missing reference frees the string when the maker returns. +type reader = () => string; + +function makeGreeter(): reader { + const greeting = "he" + "llo"; + + return () => greeting; +} + +function capturedByValueOutlivesMaker(): number { + let g = makeGreeter(); + churn(); + + return g().length; +} + +function main() { + assert(generatorOutlivesItsMaker() == 4, "a generator keeps what it captured by value"); + + const spread = spreadOfFilteredArray(); + print(spread); + assert(spread == 12, "a generator local owns the array it walks"); + + assert(generatorLocalHeldAcrossYield() == 7, "a generator local survives a yield"); + assert(capturedByValueOutlivesMaker() == 5, "a closure keeps what it captured by value"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_generators.ts b/tslang/test/tester/tests/00owned_generators.ts new file mode 100644 index 000000000..6d75504d2 --- /dev/null +++ b/tslang/test/tester/tests/00owned_generators.ts @@ -0,0 +1,106 @@ +// A generator's locals live in a state object that is built and retained before the body has +// ever run, so every reference-typed local starts out as an unwritten field of it. Each case +// here keeps such a field alive across a suspension and reads it on the other side. + +function makeNumbers(): number[] { + return [1, 2, 3]; +} + +function makeLabel(n: number): string { + return "n" + n; +} + +function* numbersFromLocal() { + const held = makeNumbers(); + yield held[0]; + yield held[2]; +} + +function localArraySurvivesSuspension(): number { + let total = 0; + for (const v of numbersFromLocal()) { + total = total + v; + } + + return total; +} + +function* labelsFromLocal() { + const prefix = makeLabel(1); + yield prefix; + yield prefix + "!"; +} + +function localStringSurvivesSuspension(): string { + let joined = ""; + for (const s of labelsFromLocal()) { + joined = joined + s; + } + + return joined; +} + +function* counting() { + yield 1; + yield 2; +} + +// The inner iterator is itself a reference-typed local of the outer generator. +function* scaling() { + for (const v of counting()) { + yield v * 10; + } +} + +function nestedGeneratorsAddUp(): number { + let total = 0; + for (const v of scaling()) { + total = total + v; + } + + return total; +} + +// `yield*` is the same shape written differently. +function* delegating() { + yield 100; + yield* counting(); +} + +function delegationAddsUp(): number { + let total = 0; + for (const v of delegating()) { + total = total + v; + } + + return total; +} + +// A local written on one resumption and read on a later one, so the field is genuinely carried +// by the state object rather than living in a single activation. +function* accumulating() { + let seen = makeNumbers(); + yield seen.length; + seen.push(4); + yield seen.length; + yield seen[3]; +} + +function localMutatedBetweenSuspensions(): number { + let total = 0; + for (const v of accumulating()) { + total = total + v; + } + + return total; +} + +function main() { + assert(localArraySurvivesSuspension() == 4, "local array survives suspension"); + assert(localStringSurvivesSuspension() == "n1n1!", "local string survives suspension"); + assert(nestedGeneratorsAddUp() == 30, "nested generators"); + assert(delegationAddsUp() == 103, "yield* delegation"); + assert(localMutatedBetweenSuspensions() == 11, "local mutated between suspensions"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_globals.ts b/tslang/test/tester/tests/00owned_globals.ts new file mode 100644 index 000000000..115962e26 --- /dev/null +++ b/tslang/test/tester/tests/00owned_globals.ts @@ -0,0 +1,105 @@ +// A global is a root: it is the only owning slot with no matching release, because it outlives +// every scope and the value in it at exit is never given back. That is why ownership skipped it - +// there is no scope to release from - and skipping it dropped the retain as well, which is a +// different thing entirely. `g = new C()` stored the instance and then gave its reference back at +// the end of the function that built it, so the global was left pointing at freed memory. +// +// Every case builds the global in one function and reads it in another, with `churn()` between, +// because a global written and read in `main` survives the bug: nothing releases until main ends. +// `nbody.ts` is the program this was found in - `init()` builds the system, and the first method +// call that reads a field out of it writes a refcount into a freed block. +// +// See docs/reference-counting-evaluation.md section 9.49. + +class Holder { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Holder(999.0); + let words = ["zz", "yy"]; + let joined = "z" + "z"; + } +} + +let instance: Holder; +let names: string[]; +let text: string; + +function buildInstance(): void { + instance = new Holder(5.0); +} + +function buildNames(): void { + names = ["ab", "cd"]; +} + +function buildText(): void { + text = "na" + "me"; +} + +function instanceOutlivesItsMaker(): number { + buildInstance(); + churn(); + + return instance.x; +} + +// The two array cases pass with the retain missing, and are kept for the other direction: a +// release into a global without a matching retain frees a live array, and they are what catches +// that. (Nothing frees the array at all without the fix - the heap copy a literal array is cast +// into is not a call result, so the end-of-block release never claimed it, and it leaked rather +// than dangled.) Reading `.length` would not fail either way, since an array value is +// { data, length } and the length survives in the copy - so both go through an element. +function arrayOutlivesItsMaker(): number { + buildNames(); + churn(); + + return names[0].length + names[1].length; +} + +function stringOutlivesItsMaker(): number { + buildText(); + churn(); + + return text.length; +} + +// The shape `nbody` actually fails on: a field read out of the global into a local of its own. +// The local retains what it binds, which is a write into the block - so a dead global is heap +// corruption here rather than a wrong answer. +function fieldOfGlobalBoundToLocal(): number { + buildNames(); + let local = names; + churn(); + + return local[0].length + local[1].length; +} + +// Overwriting a global hands the count over like any other owning slot: the incoming value gains +// an owner and the outgoing one loses one. `g = g` is the case that says the order is right - a +// release first would drop the last reference to the value being stored back. +function reassignedGlobal(): number { + buildInstance(); + instance = new Holder(7.0); + instance = instance; + churn(); + + return instance.x; +} + +function main() { + assert(instanceOutlivesItsMaker() == 5.0, "a global keeps the instance stored in it"); + assert(arrayOutlivesItsMaker() == 4, "a global keeps the array stored in it"); + assert(stringOutlivesItsMaker() == 4, "a global keeps the string stored in it"); + assert(fieldOfGlobalBoundToLocal() == 4, "a local can take a reference out of a global"); + assert(reassignedGlobal() == 7.0, "overwriting a global hands the count over"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_inline_records.ts b/tslang/test/tester/tests/00owned_inline_records.ts new file mode 100644 index 000000000..5c2ba39e5 --- /dev/null +++ b/tslang/test/tester/tests/00owned_inline_records.ts @@ -0,0 +1,118 @@ +// A record held inline - an object literal with no methods, a tuple - is not a heap block of its +// own. Its storage belongs to whoever holds it, and its fields are released by that holder. +// +// The fields slice excluded this case outright, reasoning that retaining into a record nothing +// releases would leak. Half of that was wrong. An owned local holding a record *does* release its +// fields: `ts.RetainSlot` and `ts.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 without taking or giving anything - and two such assignments of one value released it +// twice and freed it while a local still held it. +// +// The construction half needs 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 balances, which is why the construction cases below pass either way +// and are here as a guard rather than as a test of this change. +// +// The rule the fix encodes is conditional, unlike the class/object one: an inline record's field +// owns exactly when the storage under it owns. A parameter's slot and the scratch storage a +// literal is built in both answer no. +// +// See docs/reference-counting-evaluation.md section 9.23. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +// the reduced case: two inline records, each assigned the same value, both released at scope exit +function twoInlineRecordsShareAValue() { + let x = new Leaf(1); + { + let a = { item: new Leaf(9) }; + let b = { item: new Leaf(9) }; + a.item = x; + b.item = x; + } + + return x.n; +} + +// the same through a record nested inside a record, which is where the rule has to recurse +function nestedInlineRecords() { + let x = new Leaf(2); + { + let a = { inner: { item: new Leaf(9) } }; + let b = { inner: { item: new Leaf(9) } }; + a.inner.item = x; + b.inner.item = x; + } + + return x.n; +} + +// Records held as elements of an array, which is where the record predicate has to defer to the +// element one on the same reference. Coverage of that path, NOT a counting test: the releases +// here would come from the array's own release routine, and that never runs while its data block +// still carries an unconsumed birth reference. It passes either way today and gains teeth with +// everything else when the slack goes. +function recordsInsideAnArray() { + let x = new Leaf(3); + { + let arr = [{ item: new Leaf(9) }, { item: new Leaf(9) }]; + arr[0].item = x; + arr[1].item = x; + } + + return x.n; +} + +// Three records rather than two, because an array is also holding the value: the literal retains +// it legitimately, so that reference has to be spent before the unretained ones can take it past +// zero. The same arithmetic the spread case in 00owned_array_ops.ts needed. +function threeRecordsWithAnArrayHolder() { + let x = new Leaf(4); + let arr = [x]; + { + let a = { item: new Leaf(9) }; + let b = { item: new Leaf(9) }; + let c = { item: new Leaf(9) }; + a.item = x; + b.item = x; + c.item = x; + } + + return x.n + arr[0].n; +} + +// construction, which balanced already - kept as a guard on that balance +function constructionStaysBalanced() { + let kept = new Leaf(7); + { let a = { item: kept }; } + { let b = { item: kept }; } + { let c = { item: kept }; } + + return kept.n; +} + +// the ordinary path: one assignment through an inline record's field +function inlineRecordSingleAssign() { + let a = { item: new Leaf(1) }; + a.item = new Leaf(5); + + return a.item.n; +} + +function main() { + assert(twoInlineRecordsShareAValue() == 1, "a value assigned into two inline records survives both"); + assert(nestedInlineRecords() == 2, "the rule recurses through a nested record"); + assert(recordsInsideAnArray() == 3, "and through records held as array elements (coverage only)"); + assert(threeRecordsWithAnArrayHolder() == 8, "records spend their own references before the array's"); + assert(constructionStaysBalanced() == 7, "constructing a record from a value stays balanced"); + assert(inlineRecordSingleAssign() == 5, "a single assignment through a record field behaves"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_interfaces.ts b/tslang/test/tester/tests/00owned_interfaces.ts new file mode 100644 index 000000000..f7250ef23 --- /dev/null +++ b/tslang/test/tester/tests/00owned_interfaces.ts @@ -0,0 +1,170 @@ +// An interface value is a reference to whatever it was made from, and until section 9.31 it +// owned nothing: the type carries only a name, so the layout behind its `this` is not +// recoverable from it. It does not have to be - the value now carries the runtime type tag of +// its `this` beside the pointer, and release and retain go through that, the same way an `any` +// box has always worked. +// +// Making an interface an owner turned two dormant omissions into live over-releases, and both +// are guarded here. A block filled from a literal has to take a reference to what it holds +// (section 9.21) - `castTupleToInterface` allocates such a block and did not; and a value pushed +// into an array already owned has to be marked consumed - `retainInsertedElements` skipped its +// retain without saying so, and section 9.30 then released it at the end of the pushing block. +// +// Both are invisible while the block that builds is also the block that reads, which is why +// section 9.30's own tests missed them: the release goes at the END of the producing block, so a +// read in that same block still happens first. Every case here therefore builds in one block and +// reads in another, with `churn()` in between so a freed block is claimed by something else and +// a use-after-free shows up as a wrong answer rather than as the value that used to be there. +// +// See docs/reference-counting-evaluation.md section 9.31. + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +interface Holder { + v: Vec; +} + +interface Point { + x: number; +} + +interface Nested { + inner: Holder; + tag: number; +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +// the shape section 9.31 exists for: a literal boxed as an interface, handed back, and read by +// somebody else +function makeHolder(n: number): Holder { + return { v: new Vec(n) }; +} + +function returnedThroughInterface() { + let h = makeHolder(7); + churn(); + + return h.v.x; +} + +// an interface temporary passed straight as an argument and never bound - what raytrace is made +// of, and what nothing gave back before 9.31 +function readHolder(h: Holder): number { + churn(); + + return h.v.x; +} + +function argumentNeverBound() { + return readHolder({ v: new Vec(11) }); +} + +// The boxed block outlives the block that built it, so the reference it holds to `v` has to be +// one it took. Without the retain at the boxing site, `new Vec(13)` is the only owner, it is +// released at the end of makeAndKeep, and the read below finds whatever churn put there. +let kept: Holder[] = []; + +function makeAndKeep() { + kept.push({ v: new Vec(13) }); +} + +function boxedLiteralOutlivesItsBlock() { + makeAndKeep(); + churn(); + + return kept[0].v.x; +} + +// The same question for the element itself rather than for what it holds: push takes over the +// reference `new Vec(17)` arrives with, so nothing may release it afterwards. +let vecs: Vec[] = []; + +function pushOwned() { + vecs.push(new Vec(17)); +} + +function pushedResultIsNotReleased() { + pushOwned(); + churn(); + + return vecs[0].x; +} + +// an interface field inside another boxed literal: the outer block owns the inner interface, +// which owns the Vec +function makeNested(n: number): Nested { + return { inner: { v: new Vec(n) }, tag: 3 }; +} + +function interfaceHeldByInterface() { + let outer = makeNested(19); + churn(); + + return outer.inner.v.x + outer.tag; +} + +// a class instance behind an interface, where the class keeps its own owner as well +class Counter implements Point { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +function classThroughInterface() { + let c = new Counter(23); + let p: Point = c; + churn(); + + return c.x + p.x; +} + +// an interface with no `this` at all - the tag is null and both directions have to do nothing +// rather than read through it +function nullInterfaceIsInert() { + let h: Holder = undefined; + churn(); + + return h == undefined ? 29 : 0; +} + +// many interfaces built and dropped in a loop, each iteration's given back at the end of that +// iteration, with the one carried out untouched +function interfacesInALoop() { + let last = makeHolder(0); + let total = 0; + for (let i = 1; i <= 8; i++) { + last = makeHolder(i); + total = total + readHolder({ v: new Vec(i) }); + } + + churn(); + + return total + last.v.x; +} + +function main() { + assert(returnedThroughInterface() == 7, "a literal boxed as an interface survives its maker"); + assert(argumentNeverBound() == 11, "an interface argument nothing bound survives the call"); + assert(boxedLiteralOutlivesItsBlock() == 13, "a boxed literal owns what it holds"); + assert(pushedResultIsNotReleased() == 17, "a pushed owned result is not released under the array"); + assert(interfaceHeldByInterface() == 22, "an interface held by an interface stays alive"); + assert(classThroughInterface() == 46, "a class behind an interface keeps its own owner"); + assert(nullInterfaceIsInert() == 29, "a null interface releases nothing"); + assert(interfacesInALoop() == 44, "a loop's interface temporaries do not disturb what it carries out"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_iteration.ts b/tslang/test/tester/tests/00owned_iteration.ts new file mode 100644 index 000000000..76bdb833b --- /dev/null +++ b/tslang/test/tester/tests/00owned_iteration.ts @@ -0,0 +1,114 @@ +// A `for...of` over the default library's iterator protocol hands the loop variable a reference +// it then takes ownership of. When the elements come from a literal they live in read-only +// constant memory, and when the loop runs out the iterator hands back `undefined` typed as the +// element type - both of which the loop retains before it looks at them. + +function sumOfLengths(rows: number[][]): number { + let total = 0; + for (const row of rows) { + total = total + row.length; + } + + return total; +} + +function constantRowsAreIterable(): number { + const rows = [[1], [2]]; + + return sumOfLengths(rows); +} + +// Three elements is the interesting width: it is the one whose payload wants more alignment +// than the header in front of it, so a header written naively lands in the wrong place. +function raggedRowsAreIterable(): number { + const rows = [[1], [2, 3], [4, 5, 6]]; + let total = 0; + + for (const row of rows) { + for (const cell of row) { + total = total + cell; + } + } + + return total; +} + +function constantRowsSurviveTheLoop(): number { + const rows = [[1, 2], [3, 4]]; + let last = 0; + + for (const row of rows) { + last = row[1]; + } + + // the rows are still readable after the loop has given every element back + return last + rows[0][0] + rows[1][1]; +} + +function emptyRowsIterateNoTimes(): number { + const rows: number[][] = []; + let count = 0; + + for (const row of rows) { + count = count + 1; + } + + return count; +} + +function makeRecord(n: number) { + return { id: n, label: "r" + n }; +} + +function recordsAreIterable(): string { + const records = [makeRecord(1), makeRecord(2)]; + let joined = ""; + + for (const record of records) { + joined = joined + record.label; + } + + return joined; +} + +// A generator's own `{ value, done }` result carries the same `undefined` on its final call, so +// this case reaches it without the default library's iterator in the way. +function* someRecords() { + yield makeRecord(3); + yield makeRecord(4); +} + +function generatedRecordsAreIterable(): string { + let joined = ""; + for (const record of someRecords()) { + joined = joined + record.label; + } + + return joined; +} + +function* labels() { + yield "a"; + yield "b"; +} + +function generatedStringsAreIterable(): string { + let joined = ""; + for (const s of labels()) { + joined = joined + s; + } + + return joined; +} + +function main() { + assert(constantRowsAreIterable() == 2, "constant rows"); + assert(raggedRowsAreIterable() == 21, "ragged rows"); + assert(constantRowsSurviveTheLoop() == 9, "rows survive the loop"); + assert(emptyRowsIterateNoTimes() == 0, "empty rows"); + assert(recordsAreIterable() == "r1r2", "records"); + assert(generatedRecordsAreIterable() == "r3r4", "generated records"); + assert(generatedStringsAreIterable() == "ab", "generated strings"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_literals.ts b/tslang/test/tester/tests/00owned_literals.ts new file mode 100644 index 000000000..6d77ceb61 --- /dev/null +++ b/tslang/test/tester/tests/00owned_literals.ts @@ -0,0 +1,113 @@ +// An array literal's data block, and a boxed object literal's storage, release what they hold +// when they die - but neither is filled through an assignment, so before this neither took a +// reference to what it captured. The block gave up references it never had. +// +// That is not a leak. It is an over-release, and unlike everything else in step 5 it was +// reachable today, with the birth-reference slack still in place. An element seeded by a literal +// sits one below an equivalent field: the first overwrite's release is masked by the unconsumed +// birth reference, and a second release of the same value takes it past zero and frees it while +// a local still holds it. Reduced, it printed 7, 7, 7, 0 - the last read landing on freed memory. +// +// So unlike 00owned_fields.ts, and like 00owned_elements.ts, these have teeth right now. Checked +// case by case against the compiler as it stood before this change: every case below returned a +// wrong answer - 3 where 10 was due, 1 where 7 was - except plainLiteral, which is the control +// and is meant to pass either way. What a case needs to bite is one value reaching two slots that +// are both later overwritten - two literals sharing it, or one literal holding it twice. A single +// overwrite is not enough; the slack still covers that one. +// +// See docs/reference-counting-evaluation.md section 9.21. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +// the reduced case, exactly as it was found +function twoArraysShareAValue() { + let kept = new Leaf(7); + let a = [kept]; + let b = [kept]; + a[0] = new Leaf(1); + b[0] = new Leaf(2); + + return kept.n + a[0].n + b[0].n; +} + +// a third holder, to show it is not specific to the count two +function threeArraysShareAValue() { + let kept = new Leaf(7); + let a = [kept]; + let b = [kept]; + let c = [kept]; + a[0] = new Leaf(1); + b[0] = new Leaf(1); + c[0] = new Leaf(1); + + return kept.n; +} + +// one literal holding the same value in two of its slots +function oneArrayHoldsItTwice() { + let x = new Leaf(1); + let arr = [x, x]; + arr[0] = new Leaf(3); + arr[1] = new Leaf(4); + + return x.n + arr[0].n + arr[1].n; +} + +// the object-literal half: a literal with a method is boxed as a reference type, and its storage +// releases its fields the same way an array's data block releases its elements +function twoObjectLiteralsShareAValue() { + let kept = new Leaf(7); + let a = { item: kept, touch() { return this.item.n; } }; + let b = { item: kept, touch() { return this.item.n; } }; + a.item = new Leaf(1); + b.item = new Leaf(2); + + return kept.n + a.item.n + b.item.n; +} + +// one boxed literal capturing the same value in two of its fields +function oneObjectHoldsItTwice() { + let x = new Leaf(5); + let o = { p: x, q: x, touch() { return this.p.n; } }; + o.p = new Leaf(1); + o.q = new Leaf(2); + + return x.n + o.p.n + o.q.n; +} + +// a literal whose elements are themselves literals, so the retain runs on an array value rather +// than a class reference +function nestedLiterals() { + let leaf = new Leaf(9); + let outer = [[leaf], [leaf]]; + outer[0] = [new Leaf(1)]; + outer[1] = [new Leaf(2)]; + + return leaf.n + outer[0][0].n + outer[1][0].n; +} + +// the ordinary path, to show nothing was disturbed for a literal nobody else holds +function plainLiteral() { + let arr = [new Leaf(1), new Leaf(2)]; + arr[0] = new Leaf(3); + + return arr[0].n + arr[1].n; +} + +function main() { + assert(twoArraysShareAValue() == 10, "a value in two literals survives both overwrites"); + assert(threeArraysShareAValue() == 7, "and survives three"); + assert(oneArrayHoldsItTwice() == 8, "a value in two slots of one literal survives both"); + assert(twoObjectLiteralsShareAValue() == 10, "the same holds for boxed object literals"); + assert(oneObjectHoldsItTwice() == 8, "and for one boxed literal holding it twice"); + assert(nestedLiterals() == 12, "an array literal captured by another survives the overwrite"); + assert(plainLiteral() == 5, "a literal nobody else holds still behaves"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_locals.ts b/tslang/test/tester/tests/00owned_locals.ts new file mode 100644 index 000000000..a0c2a5dad --- /dev/null +++ b/tslang/test/tester/tests/00owned_locals.ts @@ -0,0 +1,169 @@ +// Locals that hold a heap reference take one when they are declared and give it back at +// every exit from their scope. Under -mm=rc that is real traffic through __tslang_inc_ref / +// __tslang_dec_ref; under a collector the ops erase and this is an ordinary program. Either +// way the results below must not change, which is what makes it a counting test: a reference +// dropped once too often frees a live value and the reads after it stop matching. + +class Node { + constructor(public v: number) {} +} + +// declaration and scope exit, one of each owning shape +function shapes() { + let s = "abc"; + let a = [1, 2, 3]; + let n = new Node(7); + let t = ["x", 1]; + let u: string | number = "y"; + + assert(s == "abc", "string local"); + assert(a[2] == 3, "array local"); + assert(n.v == 7, "class local"); + assert(t[0] == "x", "tuple local"); + assert(u == "y", "union local"); +} + +// assignment hands the count over: the incoming value gains this scope as an owner, the +// outgoing one loses it +function reassign() { + let s = "one"; + s = "two"; + s = s + "!"; + assert(s == "two!", "reassigned string"); + + let n = new Node(1); + n = new Node(2); + assert(n.v == 2, "reassigned class"); +} + +// the case retaining first rather than releasing first is there for: releasing the old value +// before the store could free the very value being stored back +function selfAssign() { + let s = "keep"; + s = s; + assert(s == "keep", "self-assigned string"); + + let n = new Node(3); + n = n; + assert(n.v == 3, "self-assigned class"); +} + +// a local declared inside a loop body is retained and released once per iteration +function loopScope() { + let total = 0; + for (let i = 0; i < 4; i++) { + let s = "ab"; + total = total + s.length; + } + + assert(total == 8, "per-iteration locals"); +} + +// break and continue leave the scope early and owe the same releases +function loopExits() { + let seen = 0; + for (let i = 0; i < 6; i++) { + let s = "x"; + if (i == 1) { + continue; + } + + if (i == 4) { + break; + } + + seen = seen + s.length; + } + + assert(seen == 3, "break and continue"); +} + +// returning an owned value must not release the reference the caller is about to receive +function makeName(id: number) { + let name = "node"; + let n = new Node(id); + if (id > 0) { + return name + n.v; + } + + return name; +} + +// a return from a nested block releases every scope it leaves +function nested(flag: boolean) { + let outer = "out"; + if (flag) { + let inner = "in"; + return outer + inner; + } + + return outer; +} + +// Paths that reach a local's slot without going through an assignment expression are where a +// missing retain would turn into a release of a reference nobody took, so each one is here on +// purpose rather than for the language feature it names. +function forOf() { + let names = ["a", "b", "c"]; + let acc = ""; + for (const n of names) { + acc = acc + n; + } + + assert(acc == "abc", "for-of binding"); +} + +function destructure() { + let pair = ["l", "r"]; + let [x, y] = pair; + assert(x == "l" && y == "r", "destructured declaration"); + + let a = "1"; + let b = "2"; + [a, b] = [b, a]; + assert(a == "2" && b == "1", "destructured assignment"); +} + +// a captured local outlives the statement that reads it, but the scope's own retain and +// release still pair up +function captured() { + let s = "cap"; + let f = () => s + "!"; + assert(f() == "cap!", "closure capture"); +} + +// enough allocation that a block freed one release too early would be handed out again +function churn() { + let total = 0; + let widest = 0; + for (let i = 0; i < 2000; i++) { + let n = new Node(i); + let s = "n" + i; + total = total + n.v; + if (s.length > widest) { + widest = s.length; + } + } + + assert(total == 1999000, "churn sum"); + assert(widest > 1, "churn strings"); +} + +function main() { + shapes(); + reassign(); + selfAssign(); + loopScope(); + loopExits(); + forOf(); + destructure(); + captured(); + churn(); + + assert(makeName(5) == "node5", "returned owned value"); + assert(makeName(0) == "node", "returned owned value, other path"); + assert(nested(true) == "outin", "return out of a nested scope"); + assert(nested(false) == "out", "return from the outer scope"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_nested_captures.ts b/tslang/test/tester/tests/00owned_nested_captures.ts new file mode 100644 index 000000000..1c3570344 --- /dev/null +++ b/tslang/test/tester/tests/00owned_nested_captures.ts @@ -0,0 +1,137 @@ +// A closure inside a closure, both naming the same outer variable. The inner box owns the +// variable's cell exactly as the outer one does, so it has to take a reference to it; without +// that, closing the inner box gives back a count nobody added and the cell is freed while the +// outer closure - and the frame - still point at it. +// +// A freed cell keeps its contents until something else is allocated over it, so every case here +// builds in one place, allocates hard, and reads somewhere else. + +let glb = 0; + +function churn(): number { + // enough traffic to hand a freed cell to somebody else + let n = 0; + for (let i = 0; i < 400; i++) { + const s = "filler " + i; + n = n + s.length; + } + + return n; +} + +function nestedLambdaSeesOuterLocal() { + glb = 0; + let a = 7; + + const outer = () => { + const inner = () => { glb = glb + a; }; + inner(); + }; + + outer(); + churn(); + assert(glb == 7, "nested lambda saw the outer local"); + assert(a == 7, "the outer local survived the inner box"); +} + +function threeLevelsDeep() { + glb = 0; + let a = 3; + + const one = () => { + const two = () => { + const three = () => { glb = glb + a; }; + three(); + }; + two(); + }; + + one(); + churn(); + assert(glb == 3, "three levels of capture"); + assert(a == 3, "the outer local survived three boxes"); +} + +function innerCapturesTwoLevelsUp() { + // the middle closure never names `a` itself - it only carries it through + glb = 0; + let a = 11; + let b = 5; + + const outer = () => { + glb = glb + b; + const inner = () => { glb = glb + a; }; + inner(); + }; + + outer(); + churn(); + assert(glb == 16, "the inner closure reached two frames up"); + assert(a == 11, "the skipped-over local survived"); +} + +function nestedLambdaMutatesOuterLocal() { + let a = 1; + + const outer = () => { + const inner = () => { a = a + 40; }; + inner(); + a = a + 1; + }; + + outer(); + churn(); + assert(a == 42, "the inner closure wrote through to the outer local"); +} + +function innerClosureOutlivesTheOuterCall() { + // The inner closure is handed back rather than called on the spot, so its box is a real + // one that nothing can fold away, and it is still holding the cell after the call that + // built it has returned. + glb = 0; + let a = 2; + + const outer = () => { + const inner = () => { glb = glb + a; }; + return inner; + }; + + const kept = outer(); + churn(); + kept(); + churn(); + kept(); + + assert(glb == 4, "the escaped inner closure still reached the cell"); + assert(a == 2, "and the cell still holds its value"); +} + +function nestedLambdaOverAString() { + // the cell holds something that owns heap memory of its own + let s = "held"; + + const outer = () => { + const inner = () => { s = s + "!"; }; + return inner; + }; + + const kept = outer(); + churn(); + kept(); + churn(); + kept(); + churn(); + + assert(s == "held!!", "the string in the captured cell survived"); +} + +function main() { + nestedLambdaSeesOuterLocal(); + threeLevelsDeep(); + innerCapturesTwoLevelsUp(); + nestedLambdaMutatesOuterLocal(); + innerClosureOutlivesTheOuterCall(); + nestedLambdaOverAString(); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_strings.ts b/tslang/test/tester/tests/00owned_strings.ts new file mode 100644 index 000000000..358ab6f45 --- /dev/null +++ b/tslang/test/tester/tests/00owned_strings.ts @@ -0,0 +1,127 @@ +// A string built at run time is a heap allocation like any other, and until section 9.37 two of +// the plainest ways to make one handed back a value nothing owned: printing a number into a +// string, and concatenating. Neither was marked as carrying a reference for its receiver, so a +// receiver added one of its own - which is balanced, and worked - while an intermediate that no +// receiver ever took was left with none at all and leaked. `"s" + k` leaks twice over: the +// conversion's result feeds the concatenation and is then forgotten. +// +// Only a leak, so the cases here cannot fail on the bug itself. What they guard is the direction +// the fix could go wrong in: a receiver now *takes over* the reference rather than adding one, +// and taking over one that was never made would free the string while it is still held. +// +// `growAndFillStrings` is the other half of the section and does fail loudly: growing an array +// exposes slots the allocator never wrote, and storing into one releases what the slot held. +// +// See docs/reference-counting-evaluation.md section 9.37. + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +class Box { + text: string; + + constructor(text: string) { + this.text = text; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +// A concatenation handed to a field: the field takes the reference over, so the string has to +// outlive the block that built it. +function boxConcat(k: number): Box { + return new Box("v" + k); +} + +// The whole string is compared rather than its length: a freed buffer often keeps its length +// long after its bytes have been written over, so `.length` is a much weaker reading of it. +function concatSurvivesItsBlock() { + let held = boxConcat(7); + churn(); + + return held.text; +} + +// The conversion on its own, with no concatenation over it. +function boxNumber(k: number): Box { + return new Box(k); +} + +function numberToStringSurvives() { + let held = boxNumber(1234); + churn(); + + return held.text; +} + +// One string, two owners. The local holds it while the box also does, and the shorter-lived of +// the two letting go must not take it with them. +function concatSharedWithALocal(): Box { + let s = "ab" + "cd"; + let held = new Box(s); + + return held; +} + +function concatHeldTwice() { + let held = concatSharedWithALocal(); + churn(); + + return held.text; +} + +// Arrays of the same shape, grown, filled and dropped, so that the block the next growth is +// handed still holds string pointers - freed ones. Dirtying the heap generally is not enough, +// and neither is doing it once: what makes an unwritten slot fatal is holding something that +// looks like a string, and eight rounds of it is what took the case from three runs in four to +// all of them. +function scratchArray() { + let scratch: string[] = []; + scratch.length = 3; + for (let i = 0; i < 3; i++) { + scratch[i] = "z" + i; + } +} + +// Growing an array hands back slots the allocator has not written, and the store into one gives +// up what the slot held first. `result.length = this.length` and then `result[i] = ..` is the +// default library's own `Array.map`, which is where this was found. +function growAndFillStrings(): string[] { + for (let i = 0; i < 8; i++) { + scratchArray(); + } + + let out: string[] = []; + out.length = 3; + for (let i = 0; i < 3; i++) { + out[i] = "e" + i; + } + + return out; +} + +function grownArrayHoldsItsStrings() { + let out = growAndFillStrings(); + churn(); + + return out[0] + out[1] + out[2]; +} + +function main() { + assert(concatSurvivesItsBlock() == "v7", "a concatenation's receiver takes it over"); + assert(numberToStringSurvives() == "1234", "printing a number into a string yields an owned one"); + assert(concatHeldTwice() == "abcd", "two owners of one string, and the first to let go is not the last"); + assert(grownArrayHoldsItsStrings() == "e0e1e2", "a grown array's new slots hold nothing to give up"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_temporaries.ts b/tslang/test/tester/tests/00owned_temporaries.ts new file mode 100644 index 000000000..2a890f4f9 --- /dev/null +++ b/tslang/test/tester/tests/00owned_temporaries.ts @@ -0,0 +1,136 @@ +// Every function retains its result on the way out, so a call hands back a reference whether or +// not the caller does anything with it. Where a receiver takes that over the pair is balanced +// (00owned_call_results.ts); where nothing does - a result passed straight as an argument and +// then dropped, which is what expression-shaped code is made of - the reference stands with no +// owner. Those are given back at the end of the block that produced them (section 9.30). +// +// What these guard is the direction that corrupts. Under-releasing a temporary only leaks, which +// is invisible from inside the program; releasing one that was still owned frees a value while +// something is still using it. Every case therefore calls `churn()` between the release point and +// the read, so a freed block is claimed by something else and a use-after-free shows up as a +// wrong answer rather than as the value that used to be there. +// +// See docs/reference-counting-evaluation.md section 9.30. + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +function times(k: number, v: Vec): Vec { + return new Vec(k * v.x); +} + +function plus(a: Vec, b: Vec): Vec { + return new Vec(a.x + b.x); +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +// the raytrace shape: every intermediate is a call result used as an argument and never bound +function nestedCallArguments() { + let v = plus(times(2, new Vec(3)), new Vec(1)); + churn(); + + return v.x; +} + +// two levels of nesting, so an intermediate is itself built from intermediates +function deeperNesting() { + let v = plus(plus(times(2, new Vec(1)), times(3, new Vec(1))), new Vec(4)); + churn(); + + return v.x; +} + +// the same expression evaluated repeatedly: each iteration's temporaries are given back at the +// end of that iteration, and the value carried out of the loop is untouched +function temporariesInALoop() { + let total = 0; + let last = new Vec(0); + for (let i = 0; i < 8; i++) { + last = plus(times(2, new Vec(i)), new Vec(1)); + total = total + last.x; + } + + churn(); + + return total + last.x; +} + +// a temporary the callee keeps: push retains what it stores, so the release of the call's own +// reference must not take the element with it +function temporaryKeptByCallee() { + let arr: Vec[] = []; + arr.push(times(2, new Vec(5))); + arr.push(new Vec(7)); + churn(); + + return arr[0].x + arr[1].x; +} + +// a result used as an argument AND bound to a local: the local's own reference has to outlive +// the argument use +function usedAsArgumentAndBound() { + let a = new Vec(6); + let b = plus(a, new Vec(1)); + churn(); + + return a.x + b.x; +} + +// The callee allocates before it reads its arguments, so a temporary released too early is not +// merely freed but overwritten before the read that needs it. Without this, `plus(a, new Vec(1))` +// reads a freed block that still happens to hold its old value and the case passes for the wrong +// reason - which is what the release-before-use probe showed about the case above. +function plusAfterChurn(a: Vec, b: Vec): Vec { + churn(); + + return new Vec(a.x + b.x); +} + +function argumentReadAfterCalleeAllocates() { + let v = plusAfterChurn(new Vec(20), new Vec(3)); + churn(); + + return v.x; +} + +// a discarded result - nothing reads it at all +function discardedResult() { + let keep = new Vec(12); + times(2, new Vec(4)); + plus(new Vec(1), new Vec(2)); + churn(); + + return keep.x; +} + +// a temporary handed to a callee that reads it through a field +function temporaryReadByCallee() { + let v = times(3, plus(new Vec(2), new Vec(2))); + churn(); + + return v.x; +} + +function main() { + assert(nestedCallArguments() == 7, "nested call arguments survive their statement"); + assert(deeperNesting() == 9, "an intermediate built from intermediates survives"); + assert(temporariesInALoop() == 79, "a loop body's temporaries do not disturb what it carries out"); + assert(temporaryKeptByCallee() == 17, "a temporary the callee keeps is not freed under it"); + assert(usedAsArgumentAndBound() == 13, "a value used as an argument is still owned by its local"); + assert(argumentReadAfterCalleeAllocates() == 23, "a temporary argument survives a callee that allocates before reading it"); + assert(discardedResult() == 12, "discarding a result does not disturb anything live"); + assert(temporaryReadByCallee() == 12, "a temporary read by a callee survives the call"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_transfer.ts b/tslang/test/tester/tests/00owned_transfer.ts new file mode 100644 index 000000000..f57da818e --- /dev/null +++ b/tslang/test/tester/tests/00owned_transfer.ts @@ -0,0 +1,123 @@ +// `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, and the reference the data block held is +// handed to whoever receives the result. That makes the result already-owned, so a receiver takes +// it over instead of adding a reference of its own. +// +// What these guard is the dangerous direction. Under-consuming a transfer only leaks, and a leak +// is not observable from inside the program - so unlike 00owned_fields.ts these cannot be given +// teeth against the bug they fix. Over-consuming is what shows: the value would lose its last +// reference and be freed while the receiver still points at it. +// +// Reading through the receiver is not enough on its own to see that, and the first version of +// this file was worthless for exactly that reason - it passed with a deliberate over-release +// injected into pop, because a freed block keeps its contents until something else claims them. +// Every case therefore calls `churn()` between the transfer and the read, which allocates enough +// same-shaped blocks to land on the freed one. That is what turns a use-after-free into a wrong +// answer instead of a lucky one. +// +// Not covered, and deliberately: an ordinary call's result. Every function retains its result on +// the way out (section 9.24), so `let y = f()` is one owner above the truth too - but knowing +// which callees do that cannot be settled at the point the call is generated. See section 9.26. +// +// See docs/reference-counting-evaluation.md section 9.26. + +class Leaf { + n: number; + + constructor(n: number) { + this.n = n; + } +} + +// Allocate over whatever has just been freed. Without this a use-after-free reads the value it +// used to hold and every assertion below passes for the wrong reason. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Leaf(999); + } +} + +// the popped value must outlive the array it came out of +function popSurvivesArrayDeath() { + let out = new Leaf(0); + { + let arr = [new Leaf(1), new Leaf(2)]; + out = arr.pop(); + } + + churn(); + return out.n; +} + +// the same from the front +function shiftSurvivesArrayDeath() { + let out = new Leaf(0); + { + let arr = [new Leaf(3), new Leaf(4)]; + out = arr.shift(); + } + + return out.n; +} + +// a transferred value stored into a field, read after the array is gone +function popIntoAField() { + let holder = new Leaf(0); + let out = new Leaf(0); + { + let arr = [new Leaf(5), new Leaf(6)]; + out = arr.pop(); + holder = out; + } + + churn(); + return out.n + holder.n; +} + +// popping every element, keeping each one +function popEverything() { + let a = new Leaf(0); + let b = new Leaf(0); + { + let arr = [new Leaf(7), new Leaf(8)]; + b = arr.pop(); + a = arr.pop(); + } + + churn(); + return a.n + b.n; +} + +// the array goes on being used after a transfer, so the remaining elements are untouched +function arrayStillUsableAfterPop() { + let arr = [new Leaf(1), new Leaf(2), new Leaf(3)]; + let last = arr.pop(); + arr[0] = new Leaf(9); + churn(); + + return last.n + arr[0].n + arr[1].n; +} + +// a transferred value put straight back into another array +function popThenPush() { + let out = new Leaf(0); + { + let src = [new Leaf(4), new Leaf(5)]; + let dst: Leaf[] = []; + dst.push(src.pop()); + out = dst[0]; + } + + return out.n; +} + +function main() { + assert(popSurvivesArrayDeath() == 2, "a popped value outlives the array it came from"); + assert(shiftSurvivesArrayDeath() == 3, "and so does a shifted one"); + assert(popIntoAField() == 12, "a transferred value survives reaching a second holder"); + assert(popEverything() == 15, "popping every element keeps every value"); + assert(arrayStillUsableAfterPop() == 14, "the array is undisturbed by a transfer"); + assert(popThenPush() == 5, "a transferred value can be handed straight to another array"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00owned_unions.ts b/tslang/test/tester/tests/00owned_unions.ts new file mode 100644 index 000000000..13b7281de --- /dev/null +++ b/tslang/test/tester/tests/00owned_unions.ts @@ -0,0 +1,113 @@ +// A tagged union carries its payload inline behind a pointer to the payload type's descriptor, +// and retaining or releasing one reads the routine to call out of that descriptor. A union that +// holds nothing yet has a null tag - which is the ordinary case, not a corner: a class instance +// arrives from calloc zeroed, and the first assignment to a union field releases the old value +// before storing the new one. + +let glb = 0; + +function churn(): number { + let n = 0; + for (let i = 0; i < 400; i++) { + const s = "filler " + i; + n = n + s.length; + } + + return n; +} + +class Holder { + tagged: number | string = 0; +} + +class Nullable { + data: number | null = 10; + + // read through a narrowing test, from inside the class - the shape a `number | null` field + // is written in practice + value(): number { + if (this.data !== null) { + return this.data; + } + + return -1; + } +} + +function firstAssignmentReleasesNothing() { + // the release of the old value runs against a zeroed field + const h = new Holder(); + h.tagged = 4; + churn(); + assert(h.tagged == 4, "the first assignment reached the field"); +} + +function reassignedUnionKeepsItsLast() { + const h = new Holder(); + h.tagged = "one"; + h.tagged = 2; + h.tagged = "three"; + churn(); + assert(h.tagged == "three", "the last value assigned is the one held"); +} + +function unionFieldHoldsAString() { + const h = new Holder(); + h.tagged = "kept" + glb; + churn(); + assert(h.tagged == "kept0", "a freshly built string in a union field survived"); +} + +function manyHoldersEachAssignedOnce() { + // every one of these releases a zeroed union on the way to holding a real value + let total = 0; + for (let i = 0; i < 200; i++) { + const h = new Holder(); + h.tagged = i; + total = total + h.tagged; + } + + churn(); + assert(total == 19900, "two hundred unions, each written once"); +} + +function narrowedUnionFieldReads() { + const n = new Nullable(); + churn(); + assert(n.value() == 10, "reading the field through a narrowing test"); +} + +function* mixed() { + yield* (function* () { + yield 1.0; + yield "two"; + yield 3.0; + })(); +} + +function generatorYieldingAUnion() { + let numbers = 0; + let strings = 0; + for (const x of mixed()) { + if (typeof x == "string") { + strings++; + } else if (typeof x == "number") { + numbers++; + } + } + + churn(); + assert(numbers == 2, "two numbers came out of the generator"); + assert(strings == 1, "and one string"); +} + +function main() { + firstAssignmentReleasesNothing(); + reassignedUnionKeepsItsLast(); + unionFieldHoldsAString(); + manyHoldersEachAssignedOnce(); + narrowedUnionFieldReads(); + generatorYieldingAUnion(); + + print("done."); +} diff --git a/tslang/test/tester/tests/00throw_in_catch.ts b/tslang/test/tester/tests/00throw_in_catch.ts new file mode 100644 index 000000000..f3bf2b36a --- /dev/null +++ b/tslang/test/tester/tests/00throw_in_catch.ts @@ -0,0 +1,90 @@ +// Throwing out of a catch clause has to end the active catch on the way. It is the same debt +// a `return`, `break` or `continue` leaving a catch already paid; a throw did not, and the +// resulting IR - a catchret emitted ahead of a call still carrying the funclet token - crashed +// the backend. +// +// This file was JIT-only when it was written, for three reasons that have all since gone +// away, and none of which were about ending the catch: +// +// - An exception escaping a catch clause was said to be lost under AOT. It was the +// CatchableType::sizeOrOffset miscompile, fixed in the commit after this file landed; see +// docs/reference-counting-evaluation.md section 9.15. +// - So was `catch (e) { new Res(); throw 2; }` crashing at run time - a call in a catch +// followed by a throw out of it. Same fix, same reason: a frame slot overwritten by a +// caught `int` copied as 8 bytes. +// - `catch (e) { thrower(); }`, a call in a catch that throws with no `throw` statement +// anywhere, was blamed on the AOT exception tables. It was neither AOT-specific nor +// exception-table-related: the MLIR inliner was erasing the throw. 00throw_inlined.ts +// covers it, and section 9.16 has the detail. + +function throwsALiteralFromCatch() { + try { + throw 1; + } + catch (e: TypeOf<1>) { + throw 2; + } +} + +// the rethrow idiom: `catch (e) { throw e; }` +function rethrows() { + try { + throw 7; + } + catch (e: TypeOf<1>) { + throw e; + } +} + +// the catch that throws is itself nested inside another try, so the new exception must reach +// the outer handler and not be re-caught by the one it was thrown from +function nestedThrowFromCatch() { + let reached = 0; + try { + try { + throw 1; + } + catch (e: TypeOf<1>) { + reached = reached + 1; + throw 2; + } + } + catch (e: TypeOf<1>) { + reached = reached + 10; + } + + return reached; +} + +// a catch that does not throw still ends normally +function plainCatch() { + let ran = 0; + try { + throw 1; + } + catch (e: TypeOf<1>) { + ran = 1; + } + + return ran; +} + +function caught(f: () => void) { + try { + f(); + } + catch (e: TypeOf<1>) { + return true; + } + + return false; +} + +function main() { + assert(caught(() => throwsALiteralFromCatch()), "a throw from a catch must reach the caller"); + assert(caught(() => rethrows()), "a rethrow from a catch must reach the caller"); + assert(nestedThrowFromCatch() == 11, "the outer handler must take it, and the inner one must not re-catch"); + assert(plainCatch() == 1, "a catch that does not throw still runs and ends normally"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00throw_inlined.ts b/tslang/test/tester/tests/00throw_inlined.ts new file mode 100644 index 000000000..3cc5660f6 --- /dev/null +++ b/tslang/test/tester/tests/00throw_inlined.ts @@ -0,0 +1,77 @@ +// Two bugs, both reached by inlining a function whose body ends in a throw. Both hit AOT and +// JIT alike, and only under --opt, because that is what turns the MLIR inliner on. +// +// 1. MLIR's inliner has a fast path for a single-block callee: it offers the block's +// terminator to the dialect's handleTerminator hook and then erases it outright, on the +// assumption that a terminator is return-like and its operands are all it had left to say. +// `ts.ThrowCall` is a terminator too, and ours only knew what to do with a return, so the +// throw was erased and the caller carried on as if the callee had returned. `callsIt` below +// compiled down to a function that does nothing but return. Fixed by declining that fast +// path for any terminator that is not a return - the multi-block path leaves it in place. +// +// 2. With the throw no longer deleted, one inlined into a catch clause crashed the backend. +// Win32ExceptionPass ends a catch region at a _CxxThrowException call by splitting the block +// ahead of it and emitting the catchret there, which puts the throw outside the funclet - +// but it also collected that same call for a "funclet" bundle, leaving it naming a pad it +// had already returned from. An end-of-catch marker is what normally keeps those two apart, +// and a throw the inliner brought in arrives without one. +// +// See docs/reference-counting-evaluation.md section 9.16. + +let steps = 0; + +function thrower() { + throw 5; +} + +// (1) a plain call with no try in sight, and the throwing call is all `callsIt` ends with +function callsIt() { + steps = steps + 1; + thrower(); +} + +// (2) the same helper called from a catch clause, so the inlined throw lands inside a funclet +function throwsFromCatch() { + try { + throw 1; + } + catch (e: TypeOf<1>) { + thrower(); + } +} + +// a callee that also has a returning path, so its throw is not the only terminator and the +// inliner takes it down the multi-block path instead - the one that was always correct +function throwsOnlyWhenAsked(doThrow: boolean) { + if (doThrow) { + throw 3; + } + + steps = steps + 10; +} + +function caught(f: () => void) { + try { + f(); + } + catch (e: TypeOf<1>) { + return true; + } + + return false; +} + +function main() { + steps = 0; + assert(caught(() => callsIt()), "an inlined helper's throw must survive the inliner"); + assert(steps == 1, "and the rest of the inlined body must still run"); + + assert(caught(() => throwsFromCatch()), "an inlined throw inside a catch clause must escape it"); + + steps = 0; + assert(caught(() => throwsOnlyWhenAsked(true)), "a conditional throw must survive too"); + assert(!caught(() => throwsOnlyWhenAsked(false)), "and the returning path must still return"); + assert(steps == 10, "the returning path must have run its body"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00try_using_catch.ts b/tslang/test/tester/tests/00try_using_catch.ts new file mode 100644 index 000000000..4abb87c58 --- /dev/null +++ b/tslang/test/tester/tests/00try_using_catch.ts @@ -0,0 +1,64 @@ +// A `using` in a try body whose catch clause makes more than one call. The cleanup funclet and +// the catch funclet then coexist in one function, and the catch funclet needs a callee-saved +// register to hold the call target. +// +// This shape used to corrupt a callee-saved register across the call under `--emit=jit --opt`, +// in every memory model: `main` kept a pointer in `rsi` and got it back with the low 32 bits +// zeroed. The cause was our own C++ EH metadata - CatchableType::sizeOrOffset said a caught +// `int` was 8 bytes, so the CRT copied 8 bytes into a 4-byte frame slot and overwrote what sat +// above it. Ahead of time nothing lived there; in the JIT's large code model a saved register +// did. Fixed by giving each catchable type its real size; see +// docs/reference-counting-evaluation.md section 9.15. +// +// Not covered here, and a different bug: putting the `using` one scope deeper - inside an `if` +// within the try body - crashes the *compiler*, in every memory model. That is the synthesized +// cleanup TryOp nesting inside a real TryOp's body, recorded in section 9.11 as already broken. + +let disposed = 0; + +class Res { + [Symbol.dispose]() { + disposed = disposed + 1; + } +} + +function twoCallsInCatch() { + try { + using r = new Res(); + throw 1; + } + catch (e: TypeOf<1>) { + print("a"); + print("b"); + } +} + +// the caller keeps a value live across the call, which is what made the clobber observable +function callerHoldsValueAcrossCall() { + disposed = 0; + twoCallsInCatch(); + return disposed; +} + +// the same overflow, one type up: a caught number is genuinely 8 bytes, so this shape has to +// keep working after narrowing `int` to 4 +function catchesANumber() { + try { + using r = new Res(); + throw 1.5; + } + catch (e: number) { + print("num"); + print("caught"); + } +} + +function main() { + assert(callerHoldsValueAcrossCall() == 1, "a using in a try body disposes once when its catch runs"); + + disposed = 0; + catchesANumber(); + assert(disposed == 1, "the same with a number-typed catch"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00using_nested_scopes.ts b/tslang/test/tester/tests/00using_nested_scopes.ts new file mode 100644 index 000000000..a0974bfc2 --- /dev/null +++ b/tslang/test/tester/tests/00using_nested_scopes.ts @@ -0,0 +1,112 @@ +// A `using` scope nested inside another scope that also has to dispose on unwind. Two shapes, +// both of which used to crash the compiler and were avoided by guards in MLIRGenImpl.h rather +// than fixed: +// +// - a `using` one scope deeper than a hand-written try's body (an `if`, or a bare `{ }`). +// - an outer `using` scope that contains an inner one. That one had its own guard, +// blockHasNestedUsing, whose cost was that the *outer* using did not dispose on unwind at +// all: it stood down from being wrapped so that the inner one could be. +// +// Both were the same bug, in Win32ExceptionPass::ToInvoke. Given an operation that was already +// an invoke, it split the block at it to make room for a new one - but an invoke already ends +// its block, so it ended up alone in the new continuation block, which every caller then erased +// it from. That left an empty block with no terminator, and the real continuation with no +// predecessors, and the empty block crashed the inliner. An invoke needs its unwind edge +// redirected, not a block. See docs/reference-counting-evaluation.md section 9.17. +// +// Still guarded, and still genuinely broken: a `using` in a catch or finally *clause* +// (blockIsInsideCatchOrFinally). Re-checked against this fix - a different cause. + +let disposed = ""; + +class Res { + name: string; + + constructor(n: string) { + this.name = n; + } + + [Symbol.dispose]() { + disposed = disposed + this.name; + } +} + +// one scope deeper than the try body, and the exception unwinds through it +function nestedInIf(flag: boolean) { + try { + if (flag) { + using r = new Res("r"); + throw 1; + } + } + catch (e: TypeOf<1>) { + disposed = disposed + "!"; + } +} + +// the same, in a bare block rather than an `if` +function nestedInBlock() { + try { + { + using b = new Res("b"); + throw 1; + } + } + catch (e: TypeOf<1>) { + disposed = disposed + "!"; + } +} + +// an outer using scope containing an inner one, with the throw after the inner scope has +// already closed: the inner disposes at its own scope exit, the outer on the unwind +function outerAndInner() { + using a = new Res("a"); + { + using c = new Res("c"); + } + throw 1; +} + +// the same pair, but the throw happens while both are still live +function outerAndInnerBothLive() { + using a = new Res("a"); + { + using c = new Res("c"); + throw 1; + } +} + +function caught(f: () => void) { + try { + f(); + } + catch (e: TypeOf<1>) { + return true; + } + + return false; +} + +function main() { + disposed = ""; + nestedInIf(true); + assert(disposed == "r!", "a using nested in an if inside a try body disposes on unwind"); + + disposed = ""; + nestedInIf(false); + assert(disposed == "", "and nothing runs when that branch is not taken"); + + disposed = ""; + nestedInBlock(); + assert(disposed == "b!", "the same for a bare nested block"); + + disposed = ""; + assert(caught(() => outerAndInner()), "the throw must reach the caller"); + assert(disposed == "ca", "inner disposes at its scope exit, outer on the unwind"); + + disposed = ""; + assert(caught(() => outerAndInnerBothLive()), "the throw must reach the caller"); + assert(disposed == "ca", "both dispose on the unwind, innermost first"); + + print("done."); +} diff --git a/tslang/test/tester/tests/00using_unwind_progress.ts b/tslang/test/tester/tests/00using_unwind_progress.ts new file mode 100644 index 000000000..0428b5469 --- /dev/null +++ b/tslang/test/tester/tests/00using_unwind_progress.ts @@ -0,0 +1,160 @@ +// A `using` declaration's cleanup region has to know how far the block it stands for actually +// got. It did not, and was wrong about it in both directions - the two defects read off the IR +// in docs/reference-counting-evaluation.md section 9.62 and left open there. +// +// The block is wrapped in a synthesized catch-less TryOp so an exception unwinding through it +// still disposes (mlirGenBlockWithUnwindCleanup). The declaration's storage is hoisted out in +// front of that TryOp, and everything else - the initializer, the store, the scope exit's own +// disposal - stays in the body. The cleanup region is reached by the unwind edge from any of +// them, and disposed unconditionally: +// +// - the body's own disposal is a call in the try body, so it unwinds to that same cleanup. +// A `[Symbol.dispose]()` that threw was therefore followed by the cleanup disposing the +// same variable a second time, and the second throw - arriving while the first was still +// unwinding - terminated the process. +// - the `using` initializer's `new` is in the try body too, so a constructor that threw left +// the cleanup disposing a slot nothing had ever been stored into, reading a vtable out of +// whatever the frame happened to hold. An access violation, every time. +// +// Both are fixed by a boolean beside the slot: false until the initializing store has run, and +// cleared before each disposal rather than after it. See mlirGenDisposeOne. + +let disposed = 0; +let steps = 0; + +class Res { + [Symbol.dispose]() { + disposed = disposed + 1; + } +} + +class Boom { + constructor(fail: boolean) { + if (fail) { + throw 1; + } + } + + [Symbol.dispose]() { + disposed = disposed + 1; + } +} + +class ThrowsOnDispose { + [Symbol.dispose]() { + disposed = disposed + 1; + throw 1; + } +} + +// the control: nothing throws, so the body disposes and the cleanup is never reached +function normalExit() { + using r = new Res(); + steps = steps + 1; +} + +// the control for the unwind leg: the throw is after the store, so the cleanup is the only +// thing that can dispose, and it must +function throwsAfterDeclaration() { + using r = new Res(); + steps = steps + 1; + throw 1; +} + +// the constructor throws, so nothing was ever stored: the cleanup must dispose nothing +function constructorThrows() { + using r = new Boom(true); + steps = steps + 1; +} + +// the same, one declaration in: the first is live and owes a dispose, the second never +// existed. Skipping the whole cleanup would pass the case above and fail this one. +function secondConstructorThrows() { + using first = new Res(); + using second = new Boom(true); + steps = steps + 1; +} + +// the body's own disposal throws. It is disposed once, and the exception carries on out +// instead of the cleanup disposing the same variable again on top of it. +function disposalThrows() { + using r = new ThrowsOnDispose(); + steps = steps + 1; +} + +// the same constructor, in a hand-written try body. A different generation path - +// mlirGen(TryStatement) rather than the synthesized mlirGenBlockWithUnwindCleanup - with the +// same hoisting and the same cleanup region, so it has the same two ways to be wrong. +function constructorThrowsInTryBody() { + try { + using r = new Boom(true); + steps = steps + 1; + } + catch (e5: TypeOf<1>) { + steps = steps + 10; + } +} + +function main() { + disposed = 0; + steps = 0; + normalExit(); + assert(disposed == 1, "the control: a normal exit disposes once"); + assert(steps == 1, "the control: the body ran"); + + disposed = 0; + steps = 0; + try { + throwsAfterDeclaration(); + assert(false, "unreachable: the function throws"); + } + catch (e1: TypeOf<1>) { + steps = steps + 1; + } + assert(disposed == 1, "the cleanup disposes what the body stored"); + assert(steps == 2, "the body ran and the throw was caught"); + + disposed = 0; + steps = 0; + try { + constructorThrows(); + assert(false, "unreachable: the constructor throws"); + } + catch (e2: TypeOf<1>) { + steps = steps + 1; + } + assert(disposed == 0, "a constructor that throws leaves nothing to dispose"); + assert(steps == 1, "the body never ran, and the throw was caught"); + + disposed = 0; + steps = 0; + try { + secondConstructorThrows(); + assert(false, "unreachable: the second constructor throws"); + } + catch (e3: TypeOf<1>) { + steps = steps + 1; + } + assert(disposed == 1, "the declaration that completed is disposed, the one that threw is not"); + assert(steps == 1, "the body never ran, and the throw was caught"); + + disposed = 0; + steps = 0; + try { + disposalThrows(); + assert(false, "unreachable: the disposal throws"); + } + catch (e4: TypeOf<1>) { + steps = steps + 1; + } + assert(disposed == 1, "a disposal that throws is not repeated by the cleanup"); + assert(steps == 2, "the body ran, and the disposal's own throw was caught"); + + disposed = 0; + steps = 0; + constructorThrowsInTryBody(); + assert(disposed == 0, "the same in a hand-written try body"); + assert(steps == 10, "the body never ran, and the try's own catch took it"); + + print("done."); +} diff --git a/tslang/test/tester/tests/03disposable.ts b/tslang/test/tester/tests/03disposable.ts new file mode 100644 index 000000000..c9c6a9662 --- /dev/null +++ b/tslang/test/tester/tests/03disposable.ts @@ -0,0 +1,29 @@ +let dispose_called = false; + +class Res { + [Symbol.dispose]() { + dispose_called = true; + print("disposed"); + } +} + +// no try/catch here at all: the exception must unwind straight through this using-scope, +// and dispose must still run on the way out +function inner() { + using r = new Res(); + print("in inner"); + throw 1; +} + +function main() { + try { + inner(); + } + catch (e: TypeOf<1>) { + print("caught"); + } + + assert(dispose_called, "dispose is not called when unwinding through a using with no enclosing try"); + + print("done."); +} diff --git a/tslang/test/tester/tests/03union_type_case_order.ts b/tslang/test/tester/tests/03union_type_case_order.ts new file mode 100644 index 000000000..50273ed31 --- /dev/null +++ b/tslang/test/tester/tests/03union_type_case_order.ts @@ -0,0 +1,55 @@ +// Narrowing a discriminated union in a `switch` must not touch the payload until the +// discriminant has been compared. This program is ordinary and well typed - the only thing +// that makes it interesting is that the case carrying the LARGEST member is tested first, +// so a value of the smallest member reaches that test without matching an earlier case. +// +// The narrowing used to be emitted into the condition block, which reinterpreted the payload +// as NetworkSuccessState and retained it - and a union carries its members in a slot sized +// for the largest, so everything above a smaller member is uninitialized. Under `-mm=rc` +// those bytes were walked as string pointers and the program faulted before printing. +// +// The member tested first has to hold references for this to bite: a bigger member made only +// of numbers has nothing for the reference counting to walk. + +type NetworkLoadingState = { + state: "loading"; +}; + +type NetworkFailedState = { + state: "failed"; + code: number; +}; + +type NetworkSuccessState = { + state: "success"; + response: { + title: string; + summary: string; + }; +}; + +type NetworkState = + | NetworkLoadingState + | NetworkFailedState + | NetworkSuccessState; + +function logger(state: NetworkState): string { + switch (state.state) { + case "success": + return `Downloaded ${state.response.title} - ${state.response.summary}`; + case "failed": + return `Error ${state.code} downloading`; + case "loading": + return "Downloading..."; + default: + return ""; + } +} + +function main() { + assert(logger({ state: "loading" }) == "Downloading..."); + assert(logger({ state: "failed", code: 1.0 }) == "Error 1 downloading"); + assert(logger({ state: "success", response: { title: "title", summary: "summary" } }) == "Downloaded title - summary"); + + print("done."); +} diff --git a/tslang/test/tester/tests/04disposable.ts b/tslang/test/tester/tests/04disposable.ts new file mode 100644 index 000000000..9acf9397d --- /dev/null +++ b/tslang/test/tester/tests/04disposable.ts @@ -0,0 +1,132 @@ +// 03disposable.ts covers the shape that first exposed the gap: a `using` at a function's own +// top level, unwinding with no enclosing try. This file covers the scopes that were left out +// then and are handled now - a `using` in a nested block, in a loop body, and one that shares +// its function with a `return`. + +let disposed = 0; + +class Res { + [Symbol.dispose]() { + disposed = disposed + 1; + } +} + +// a `using` inside an if-block, not the function's own body +function inNestedBlock(f: boolean) { + if (f) { + using r = new Res(); + throw 1; + } +} + +// a `using` inside a loop body +function inLoopBody() { + for (let i = 0; i < 1; i++) { + using r = new Res(); + throw 1; + } +} + +// two scopes deep, to show it is not just one level +function inDeepBlock() { + for (let i = 0; i < 2; i++) { + if (i == 1) { + using r = new Res(); + throw 1; + } + } +} + +// a function that both throws past a `using` and returns normally past one: the unwind path +// and the ordinary path each owe exactly one dispose, not two and not none +function throwsOrReturns(f: boolean) { + using r = new Res(); + if (f) { + throw 1; + } + + return; +} + +// the synthesized cleanup nests inside a hand-written try without disturbing it +function insideHandWrittenTry() { + try { + using r = new Res(); + throw 1; + } + catch (e: TypeOf<1>) { + print("caught inner"); + } +} + +// A `using` inside a catch clause. Synthesizing the unwind cleanup here crashes the compiler, +// so this block must be left on the plain dispose path - blockIsInsideCatchOrFinally. Covered +// because dropping the old root-body condition briefly re-enabled the wrapping here and no +// test noticed. +function insideCatchClause() { + try { + throw 1; + } + catch (e: TypeOf<1>) { + using r = new Res(); + print("in catch"); + } +} + +// the same for a finally clause +function insideFinallyClause() { + try { + print("try body"); + } + finally { + using r = new Res(); + print("in finally"); + } +} + +function expectThrow(f: () => void) { + try { + f(); + } + catch (e: TypeOf<1>) { + return true; + } + + return false; +} + +function main() { + disposed = 0; + assert(expectThrow(() => inNestedBlock(true)), "nested block must throw"); + assert(disposed == 1, "using in a nested block must dispose while unwinding"); + + disposed = 0; + assert(expectThrow(() => inLoopBody()), "loop body must throw"); + assert(disposed == 1, "using in a loop body must dispose while unwinding"); + + disposed = 0; + assert(expectThrow(() => inDeepBlock()), "deep block must throw"); + assert(disposed == 1, "using two scopes deep must dispose while unwinding"); + + disposed = 0; + assert(expectThrow(() => throwsOrReturns(true)), "throwing path must throw"); + assert(disposed == 1, "a using sharing its function with a return must still dispose on throw"); + + disposed = 0; + throwsOrReturns(false); + assert(disposed == 1, "the ordinary return path must dispose exactly once"); + + disposed = 0; + insideHandWrittenTry(); + assert(disposed == 1, "a using inside a hand-written try must dispose exactly once"); + + disposed = 0; + insideCatchClause(); + assert(disposed == 1, "a using inside a catch clause must dispose exactly once"); + + disposed = 0; + insideFinallyClause(); + assert(disposed == 1, "a using inside a finally clause must dispose exactly once"); + + print("done."); +} diff --git a/tslang/test/tester/tests/export_owned_returns.ts b/tslang/test/tester/tests/export_owned_returns.ts new file mode 100644 index 000000000..af63d69ca --- /dev/null +++ b/tslang/test/tester/tests/export_owned_returns.ts @@ -0,0 +1,32 @@ +namespace M { + + // Exports every shape whose returned reference an importing module has to take over: + // a plain method, a virtual one that a subclass overrides, and one reached through an + // interface. All of them hand back a freshly built string, so a caller that keeps + // retaining leaks and a caller that releases twice frees a live one. + // See docs/reference-counting-evaluation.md item 5al. + + export interface Describable { + describe(): string; + } + + export class Animal implements Describable { + name: string; + + constructor(name: string) { + this.name = name; + } + + speak(): string { + return `${this.name} makes a noise.`; + } + + describe(): string { + return `animal ${this.name}`; + } + } + + export function greet(who: string): string { + return `hello ${who}`; + } +} diff --git a/tslang/test/tester/tests/export_root_var.ts b/tslang/test/tester/tests/export_root_var.ts new file mode 100644 index 000000000..bfe56c277 --- /dev/null +++ b/tslang/test/tester/tests/export_root_var.ts @@ -0,0 +1,17 @@ +// The library half of the entry-point pair. Its root only declares things and initializes a +// variable, which is what a library looks like - nothing here is a program, so nothing here +// needs a `main`. It is compiled with --emit=obj, the same action the program half uses, so +// the emit action cannot tell the two apart: only --entry-point does, and the program half +// gets it. +// +// Once a root variable statement alone was enough to ask for an entry point, this file grew a +// `main` too and the link failed with "duplicate symbol: main". Nothing at the root may be a +// code statement, or an entry function gets built for its own sake and the file stops testing +// this. + +export let counter = 41; + +export function bump() { + counter = counter + 1; + return counter; +} diff --git a/tslang/test/tester/tests/import_owned_returns.ts b/tslang/test/tester/tests/import_owned_returns.ts new file mode 100644 index 000000000..cd4779cce --- /dev/null +++ b/tslang/test/tester/tests/import_owned_returns.ts @@ -0,0 +1,62 @@ +import './export_owned_returns' + +// A module can only see a declaration of an imported function, never its returns, so +// OwnedReturnConsumptionPass used to refuse to classify every one of them and their results +// were never consumed. That leaked outright - measured on a two-module program whose work is +// an imported method returning a string, `rc` held 18.0 MB against `none`'s 18.0 and `gc`'s +// 5.7, so reference counting reclaimed nothing at all across the boundary - and it also +// poisoned the candidate set for the whole member name, so a LOCAL subclass's calls stopped +// being consumed too. See docs/reference-counting-evaluation.md item 5al. +// +// This is the other side of that fix: consuming a reference the callee never handed over +// would free a live string. So the test holds on to results, allocates hard over anything +// wrongly freed, and only then reads them back - a freed block keeps its contents until +// something reuses it, which is what makes the churn load-bearing rather than decorative. + +class Dog extends M.Animal { + constructor(name: string) { + super(name); + } + + speak(): string { + return `${this.name} barks.`; + } +} + +function main() { + const a = new M.Animal("Generic"); + const d = new Dog("Mitzie"); + const asBase: M.Animal = d; + const asIface: M.Describable = a; + + // every shape, held rather than dropped + let kept: string[] = []; + for (let i = 0; i < 50; i++) { + kept.push(a.speak()); // imported method, direct + kept.push(d.speak()); // local override - the call the imported one poisoned + kept.push(asBase.speak()); // virtual dispatch to the local override + kept.push(asIface.describe()); // through an imported interface + kept.push(M.greet("world")); // plain imported function + } + + // reuse anything that was freed too early + let churn = 0; + for (let i = 0; i < 20000; i++) { + churn = churn + a.speak().length + M.greet("x").length; + } + + let bad = 0; + for (let i = 0; i < 50; i++) { + const at = i * 5; + if (kept[at] != "Generic makes a noise.") bad = bad + 1; + if (kept[at + 1] != "Mitzie barks.") bad = bad + 1; + if (kept[at + 2] != "Mitzie barks.") bad = bad + 1; + if (kept[at + 3] != "animal Generic") bad = bad + 1; + if (kept[at + 4] != "hello world") bad = bad + 1; + } + + assert(bad == 0, "imported call results were freed while still referenced"); + assert(churn == 20000 * ("Generic makes a noise.".length + "hello x".length), "churn"); + + print("done."); +} diff --git a/tslang/test/tester/tests/import_root_var.ts b/tslang/test/tester/tests/import_root_var.ts new file mode 100644 index 000000000..1dd003167 --- /dev/null +++ b/tslang/test/tester/tests/import_root_var.ts @@ -0,0 +1,11 @@ +// The program half: its root has code, so it has an entry point either way. See +// export_root_var.ts for what this pair is actually testing. + +import './export_root_var' + +const v = bump(); +print(v); + +assert(v == 42); + +print("done."); diff --git a/tslang/test/tester/verify-ownership.cmake b/tslang/test/tester/verify-ownership.cmake new file mode 100644 index 000000000..16a83f452 --- /dev/null +++ b/tslang/test/tester/verify-ownership.cmake @@ -0,0 +1,79 @@ +# Runs the ownership verifier over one shard of the corpus. +# +# `--verify-ownership` is an affine-level pass, so this is a compile and not a run: about 80ms a +# file, no linker, no JIT. It asks one question of every function - does a slot that takes a +# reference give it back on every path out, unwind paths included - and it has answered "no" +# twice for real: the break/continue scope-exit bug of section 9.18, and the two nested `using` +# scopes of section 9.62. Both times it was run by hand, and nothing repeated the run, which is +# why the second pair sat open for as long as it did. This is that sweep, repeated. +# +# One memory model is enough. The ownership operations survive to this level whatever the model +# is - they are only erased on the way to LLVM - so `-mm=gc` and `-mm=none` report exactly what +# `-mm=rc` reports here; the model is named only because the pass has to pick one. +# +# Sharded purely so ctest can spread the cost; the shards are one sweep, not one test each. + +# Run with `cmake -P`, so no policy version is inherited from the project. Without this, +# CMP0057 defaults to OLD and `IN_LIST` below is not an operator. +cmake_minimum_required(VERSION 3.17.3) + +if(NOT DEFINED TSLANG OR NOT DEFINED TESTS_DIR OR NOT DEFINED SHARD OR NOT DEFINED SHARDS) + message(FATAL_ERROR "TSLANG, TESTS_DIR, SHARD and SHARDS are all required") +endif() + +if(NOT DEFINED MODEL) + set(MODEL "rc") +endif() + +# Files that do not compile under these flags for reasons of their own, and so have nothing to +# report. Kept as a list rather than by ignoring the exit code, so that a file which stops +# compiling for a NEW reason - a crash, most of all - fails this test instead of passing it +# silently. +set(not_compilable_alone + # needs a companion module that is not on this command line + 00switch_state.ts + import_vars.ts + # written for `-nostrictnull` + raytrace-0.ts) + +file(GLOB corpus "${TESTS_DIR}/*.ts") +list(SORT corpus) + +set(failures "") +set(checked 0) +set(index 0) +foreach(file ${corpus}) + math(EXPR bucket "${index} % ${SHARDS}") + math(EXPR index "${index} + 1") + if(NOT bucket EQUAL SHARD) + continue() + endif() + + get_filename_component(name "${file}" NAME) + if(name IN_LIST not_compilable_alone) + continue() + endif() + + execute_process( + COMMAND "${TSLANG}" --emit=mlir-affine "-mm=${MODEL}" --verify-ownership --no-default-lib "${file}" + OUTPUT_QUIET + ERROR_VARIABLE diagnostics + RESULT_VARIABLE status) + + math(EXPR checked "${checked} + 1") + + if(diagnostics MATCHES "error: ownership:") + string(REGEX MATCHALL "[^\n]*error: ownership:[^\n]*" reported "${diagnostics}") + string(REPLACE ";" "\n " reported "${reported}") + list(APPEND failures " ${name}\n ${reported}") + elseif(NOT status EQUAL 0) + list(APPEND failures " ${name}\n did not compile (exit ${status}), so nothing was checked") + endif() +endforeach() + +if(failures) + string(REPLACE ";" "\n" report "${failures}") + message(FATAL_ERROR "ownership verifier, shard ${SHARD} of ${SHARDS}:\n${report}") +endif() + +message(STATUS "ownership verifier clean over ${checked} files (shard ${SHARD} of ${SHARDS}, -mm=${MODEL})") diff --git a/tslang/tslang/.vscode/launch.json b/tslang/tslang/.vscode/launch.json index 81b30496f..2fe523650 100644 --- a/tslang/tslang/.vscode/launch.json +++ b/tslang/tslang/.vscode/launch.json @@ -691,7 +691,7 @@ "args": [ "-emit=exe", "-opt", - "--nogc", + "-mm=none", "-o=c:/temp/1.wasm", "-mtriple=wasm32-unknown-unknown", "C:/temp/1.ts" @@ -723,7 +723,7 @@ "-emit=exe", "-mlir-disable-threading", "-debug-only=pass", - "--nogc", + "-mm=none", "-o=c:/temp/1.wasm", "-mtriple=wasm32-pc-emscripten", "--emsdk-sysroot-path=C:/utils/emsdk/upstream/emscripten/cache/sysroot", @@ -818,7 +818,7 @@ "program": "${workspaceFolder}/../../__build/tslang/windows-msbuild-2026-debug/bin/tslang.exe", "args": [ "-emit=jit", - "-nogc", + "-mm=none", "-dump-object-file", "-object-filename=out.o", "C:/temp/1.ts" diff --git a/tslang/tslang/CMakeLists.txt b/tslang/tslang/CMakeLists.txt index bc9a69e22..d1e6b941d 100644 --- a/tslang/tslang/CMakeLists.txt +++ b/tslang/tslang/CMakeLists.txt @@ -73,6 +73,29 @@ llvm_update_compile_flags(tslang) target_link_libraries(tslang PRIVATE ${LIBS}) target_compile_definitions(tslang PUBLIC TSLANG_PACKAGE_VERSION="${TSLANG_PACKAGE_VERSION}") +# TSLANG_PACKAGE_VERSION is only set by a release build (CI passes the tag via +# -DTSLANG_PACKAGE_VERSION / the TSLANG_PACKAGE_VERSION env var); a plain dev +# build leaves it empty, so give `tslang --version` the commit it was built +# from instead of falling back on Version.h's "0.0.0-not-set" placeholder. +find_package(Git QUIET) +if(GIT_EXECUTABLE) + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE TSLANG_GIT_COMMIT_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE TSLANG_GIT_RESULT) + if(NOT TSLANG_GIT_RESULT EQUAL 0) + set(TSLANG_GIT_COMMIT_HASH "") + endif() +endif() + +if(TSLANG_GIT_COMMIT_HASH) + target_compile_definitions(tslang PUBLIC TSLANG_GIT_COMMIT_HASH="${TSLANG_GIT_COMMIT_HASH}") +endif() + MESSAGE (STATUS "VERSION = " ${TSLANG_PACKAGE_VERSION}) +MESSAGE (STATUS "GIT COMMIT HASH = " ${TSLANG_GIT_COMMIT_HASH}) mlir_check_all_link_libraries(tslang) diff --git a/tslang/tslang/cmake.cpp b/tslang/tslang/cmake.cpp index 33852a0a4..45c6c8a49 100644 --- a/tslang/tslang/cmake.cpp +++ b/tslang/tslang/cmake.cpp @@ -21,6 +21,10 @@ int substitute(StringRef data, StringMap &values, SmallString<128> &r string getExecutablePath(const char *); string fixpath(string, const SmallVectorImpl&); +string getGCLibPath(); +string getLLVMLibPath(); +string getTslangLibPath(); +string getDefaultLibPath(); int createCMakeFolder(int argc, char **argv) { @@ -48,10 +52,33 @@ int createCMakeFolder(int argc, char **argv) WithColor::error(errs(), "tslang") << "Can't get info about current folder: " << error_code.message() << "\n"; return -1; } - + StringMap vals; vals["PROJECT"] = projectName; + // add common params + SmallVector args(argv, argv + 1); + auto driverPath = getExecutablePath(args[0]); + + SmallVector appPath{}; + appPath.append(driverPath.begin(), driverPath.end()); + path::remove_filename(appPath); + + auto tslangCmd = fixpath(driverPath, appPath); + auto gcLibPath = fixpath(getGCLibPath(), appPath); + auto llvmLibPath = fixpath(getLLVMLibPath(), appPath); + auto tslangLibPath = fixpath(getTslangLibPath(), appPath); + auto defaultLibPath = fixpath(getDefaultLibPath(), appPath); + // hint for finding tslang app (same logic as in createVSCodeFolder) + auto tslangAppPath = fixpath(string(appPath.begin(), appPath.end()), appPath); + + vals["TSLANG_CMD"] = tslangCmd; + vals["GC_LIB_PATH"] = gcLibPath; + vals["LLVM_LIB_PATH"] = llvmLibPath; + vals["TSLANG_LIB_PATH"] = tslangLibPath; + vals["DEFAULT_LIB_PATH"] = defaultLibPath; + vals["TSLANG_APP_PATH"] = tslangAppPath; + StringRef cmakeLists(CMAKE_LISTS_TXT_DATA); SmallString<128> resultCMakeLists; substitute(cmakeLists, vals, resultCMakeLists); @@ -86,7 +113,48 @@ int createCMakeFolder(int argc, char **argv) return -1; } + StringRef tsconfig(TSCONFIG_JSON_DATA); + SmallString<128> result; + + vals["INCLUDE"] = "[\"mycode.ts\", \"adder.ts\"]"; // default include + + substitute(tsconfig, vals, result); + + if (auto error_code = create_file_base("tsconfig.json", result.str())) + { + return -1; + } + + if (auto error_code = create_file_base("tslang.natvis", TSLANG_NATVIS)) + { + return -1; + } + + // types folder + if (auto error_code = fs::create_directories(TYPES_TSLANG_PATH)) + { + WithColor::error(errs(), "tslang") << "Could not create folder/directory '" << TYPES_TSLANG_PATH << "' : " << error_code.message() << "\n"; + return -1; + } + + if (auto error_code = fs::set_current_path(TYPES_TSLANG_PATH)) + { + WithColor::error(errs(), "tslang") << "Can't open folder/directory '" << TYPES_TSLANG_PATH << "' : " << error_code.message() << "\n"; + return -1; + } + + if (auto error_code = create_file_base("index.d.ts", TSLANG_INDEX_D_TS)) + { + return -1; + } + // cmake folder + if (auto error_code = fs::set_current_path(projectPath)) + { + WithColor::error(errs(), "tslang") << "Can't open folder/directory '" << projectPath << "' : " << error_code.message() << "\n"; + return -1; + } + if (auto error_code = fs::create_directory(CMAKE_FOLDER_PATH)) { WithColor::error(errs(), "tslang") << "Could not create folder/directory '" << CMAKE_FOLDER_PATH << "' : " << error_code.message() << "\n"; @@ -99,17 +167,6 @@ int createCMakeFolder(int argc, char **argv) return -1; } - // hint for finding tslang app (same logic as in createVSCodeFolder) - SmallVector args(argv, argv + 1); - auto driverPath = getExecutablePath(args[0]); - - SmallVector appPath{}; - appPath.append(driverPath.begin(), driverPath.end()); - path::remove_filename(appPath); - - auto tslangAppPath = fixpath(string(appPath.begin(), appPath.end()), appPath); - vals["TSLANG_APP_PATH"] = tslangAppPath; - StringRef determineCompiler(CMAKE_DETERMINE_TSLANG_COMPILER_DATA); SmallString<128> resultDetermineCompiler; substitute(determineCompiler, vals, resultDetermineCompiler); diff --git a/tslang/tslang/defaultlib.cpp b/tslang/tslang/defaultlib.cpp index 61ee1bb1d..c9b5cf32e 100644 --- a/tslang/tslang/defaultlib.cpp +++ b/tslang/tslang/defaultlib.cpp @@ -53,13 +53,13 @@ std::string getDefaultLibPath(); std::string getpath(std::string, const SmallVectorImpl&); std::error_code copy_from_to(const SmallVectorImpl&, const SmallVectorImpl&); -bool checkFileExistsAtPath(const SmallVectorImpl& path, std::string sub1, std::string sub2, std::string sub3, std::string fileName) +bool checkFileExistsAtPath(const SmallVectorImpl& path, std::string subPath, std::string fileName) { llvm::SmallVector destPath(0); destPath.reserve(256); destPath.append(path); - llvm::sys::path::append(destPath, sub1, sub2, sub3, fileName); + llvm::sys::path::append(destPath, subPath, fileName); if (!llvm::sys::fs::exists(destPath)) { return false; @@ -137,13 +137,13 @@ int installDefaultLib(int argc, char **argv) return -1; } - // The release build is always produced; verify its static lib landed in the - // per-build subfolder (defaultlib/lib/release/...). + // The release build of the default memory model is always produced; verify its static lib + // landed in the subfolder the compiler will later look in. Built from getDefaultLibSubDir + // rather than spelled out here, so this check cannot drift away from what exe.cpp and + // jit.cpp resolve (defaultlib/lib/release/gc/...). auto result = checkFileExistsAtPath( builtPath, - DEFAULT_LIB_DIR, - "lib", - DEFAULT_LIB_BUILD_DIR_RELEASE, + getDefaultLibSubDir(/*shared=*/false, /*debugBuild=*/false, memoryModelName(MemoryModelGC)), #ifdef WIN32 DEFAULT_LIB_NAME ".lib" #else diff --git a/tslang/tslang/exe.cpp b/tslang/tslang/exe.cpp index 147cef1be..500e5fa89 100644 --- a/tslang/tslang/exe.cpp +++ b/tslang/tslang/exe.cpp @@ -25,7 +25,6 @@ namespace cl = llvm::cl; extern cl::opt emitAction; extern cl::opt outputFilename; -extern cl::opt disableGC; extern cl::opt TargetTriple; extern cl::opt defaultlibpath; extern cl::opt gclibpath; @@ -397,16 +396,36 @@ int buildExe(int argc, char **argv, std::string objFileName, std::string additio // default lib path (per-build subfolder: debug/release must match how // this program is being compiled so the CRT and default-lib binaries agree). // Keyed on --di (generate debug info): with debug info use the debug lib. - auto defaultLibBuildDir = compileOptions.generateDebugInfo ? DEFAULT_LIB_BUILD_DIR_DEBUG : DEFAULT_LIB_BUILD_DIR_RELEASE; - auto defaultLibSubDir = std::string(shared ? DEFAULT_LIB_DIR "/dll/" : DEFAULT_LIB_DIR "/lib/") + defaultLibBuildDir; - defaultLibPathOpt = getLibsPathOpt(mergeWithDefaultLibPath(getDefaultLibPath(), defaultLibSubDir)); + // ...and per memory model: the default lib allocates the way the model it was built for + // allocates, so a `gc` build linked into an `-mm=rc` program would drag Boehm in and hand + // back objects this program's ownership rules do not describe. See getDefaultLibSubDir. + auto defaultLibSubDir = getDefaultLibSubDir(shared, compileOptions.generateDebugInfo, + memoryModelName(compileOptions.memoryModel)); + auto defaultLibDir = mergeWithDefaultLibPath(getDefaultLibPath(), defaultLibSubDir); + + // Checked here rather than left to the linker. mergeWithDefaultLibPath only joins the + // path, so a model that has not been built reaches lld as a `-L` to nowhere and comes + // back as "cannot open input file 'TypeScriptDefaultLib.lib'", which says nothing about + // which model is missing or how to get it. Deliberately not a fallback to another + // model's build either: the wrong one links and then misbehaves at run time, which is + // far harder to diagnose than a directory that is not there. + if (!defaultLibDir.empty() && !llvm::sys::fs::is_directory(defaultLibDir)) + { + llvm::errs() << "error: no default library built for -mm=" + << memoryModelName(compileOptions.memoryModel) << ": " << defaultLibDir + << " does not exist. Build it (see the default-lib build scripts), " + << "or compile with --no-default-lib.\n"; + return 1; + } + + defaultLibPathOpt = getLibsPathOpt(defaultLibDir); if (!defaultLibPathOpt.empty()) { - args.push_back(defaultLibPathOpt.c_str()); - } + args.push_back(defaultLibPathOpt.c_str()); + } } - if (!disableGC) + if (compileOptions.needsGCRuntime()) { gcLibPathOpt = getLibsPathOpt(getGCLibPath()); if (!gcLibPathOpt.empty()) @@ -463,7 +482,7 @@ int buildExe(int argc, char **argv, std::string objFileName, std::string additio } // tslang libs - if (!disableGC) + if (compileOptions.needsGCRuntime()) { args.push_back("-lgc"); } diff --git a/tslang/tslang/jit.cpp b/tslang/tslang/jit.cpp index 735498ecf..b9d34704b 100644 --- a/tslang/tslang/jit.cpp +++ b/tslang/tslang/jit.cpp @@ -14,12 +14,17 @@ #include "llvm/IR/LLVMContext.h" #include "llvm/IR/Module.h" #include "llvm/Support/DynamicLibrary.h" +#include "llvm/Support/FileSystem.h" #include "llvm/Support/Memory.h" #include "llvm/Support/Path.h" #include +#include #ifdef _WIN32 +#include #include +#else +#include #endif #include "llvm/TargetParser/Host.h" @@ -42,7 +47,6 @@ extern cl::opt sizeLevel; extern cl::list clSharedLibs; extern cl::opt dumpObjectFile; extern cl::opt objectFilename; -extern cl::opt disableGC; extern cl::opt mainFuncName; extern cl::opt inputFilename; @@ -118,7 +122,7 @@ static uint64_t jitImageBase = 0; // in the JIT an object reachable only from a global (e.g. a static class member) // is collected on the first GC cycle and its memory recycled. Register every RW // data section via GC_add_roots, resolved dynamically from the already-loaded -// TypeScriptRuntime library so this stays inert under --nogc. +// TypeScriptRuntime library so this stays inert under -mm=none. // // 2. Win64 unwind info. LLVM's RTDyld never registers .pdata with the OS // (RTDyldMemoryManager::registerEHFramesInProcess only speaks the Itanium @@ -137,8 +141,29 @@ static uint64_t jitImageBase = 0; class JitSectionMemoryManager : public llvm::SectionMemoryManager { using GCRootsFn = void (*)(void *, void *); + // (see jitEnableGCThreads below for the matching stand-in) public: + // 4. One contiguous reservation, laid out code first (item 5am). + // + // RTDyld resolves every IMAGE_REL_AMD64_ADDR32NB relocation against an "image base" it + // defines as the LOWEST section load address, so whatever datum lands there has RVA 0 - and + // RVA 0 is what the MSVC C++ EH encoding uses as its "none" sentinel. A catch clause whose + // `??_R0*@8` type descriptor landed at the base therefore read `dispType == 0`, which is + // `catch(...)`: the clause still caught, so nothing looked wrong, but a catch-all has no + // catch object, so the value was never copied and the variable read uninitialised stack. + // Ahead of time this cannot happen - RVA 0 of a PE is the DOS header, never a datum. + // + // `reserveAllocationSpace` takes one block and lays it out code, then read-only, then + // read-write, so the lowest section is always code. No field in the MSVC EH encoding reads a + // *code* RVA of 0 as "none", so the collision has nowhere left to land. Section 9.66. + // + // The cost of the flag is that all memory is pre-allocated from the sizes RTDyld computes up + // front, and an allocation beyond them fails rather than growing. + JitSectionMemoryManager() : llvm::SectionMemoryManager(nullptr, /*ReserveAlloc=*/true) + { + } + uint8_t *allocateCodeSection(uintptr_t size, unsigned alignment, unsigned sectionID, llvm::StringRef sectionName) override { @@ -235,6 +260,46 @@ class JitSectionMemoryManager : public llvm::SectionMemoryManager #endif }; +// Stands in for the async runtime's GC_enable_threads when a JIT run has no TypeScriptRuntime.dll +// to provide it - see the site in runJit that decides whether to install it. Its job is to let the +// program RUN: the GC pass puts that call in every `gc` entry point, so without a definition the +// module does not materialize at all, whether or not it has an await in it. +// +// It is a stand-in, not the fix. It forwards to the collector's own GC_allow_register_threads if +// the process exports one, and does nothing if not; and it cannot do the other half at all - +// registering the pool's worker threads needs the runtime that owns those threads. A `gc` program +// awaiting in a long loop is therefore still exposed to §9.57's race in this configuration, as it +// was before any of this. Pass `--shared-libs=TypeScriptRuntime.dll` to get the real one. +// +// Resolved dynamically rather than called directly because tslang.exe does not link the collector. +static void jitEnableGCThreads() +{ + if (auto *allowRegisterThreads = llvm::sys::DynamicLibrary::SearchForAddressOfSymbol("GC_allow_register_threads")) + { + reinterpret_cast(allowRegisterThreads)(); + } +} + +// A failing `assert` in compiled code calls `_assert`, and under --emit=jit that call lands +// in whichever CRT the process resolver reaches first - ucrtbase.dll, whose report mode is +// nobody's to set from here, and which puts the failure up as a modal message box. In an +// unattended run that is not a failure but a hang: the harness waits on a window nobody is +// there to close, and a whole test tier can stall on one bad assertion. Answer the call here +// instead. The lowering passes the source position along, so this says more than the box did. +static void jitAssertFailed(const char *message, const char *file, unsigned line) +{ + if (file != nullptr && *file != '\0') + { + fprintf(stderr, "%s:%u: ", file, line); + } + + fprintf(stderr, "assertion failed: %s\n", message != nullptr ? message : "assert"); + fflush(stderr); + + // not abort(): that has a dialog of its own to put up + _exit(3); +} + #ifdef _WIN64 // MSVC x64 C++ EH encodes throw-site type information as image-relative offsets. // vcruntime's _CxxThrowException recovers the base with RtlPcToFileHeader on the @@ -295,14 +360,34 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile { // per-build subfolder (debug/release) must match the JIT compilation mode. // Keyed on --di (generate debug info): with debug info use the debug lib. - auto defaultLibBuildDir = compileOptions.generateDebugInfo ? DEFAULT_LIB_BUILD_DIR_DEBUG : DEFAULT_LIB_BUILD_DIR_RELEASE; - clSharedLibs.push_back(mergeWithDefaultLibPath(getDefaultLibPath(), + // ...and per memory model, for the same reason the linker path is: the default lib + // allocates the way the model it was built for allocates. See getDefaultLibSubDir. + auto defaultLibSubDir = + getDefaultLibSubDir(/*shared=*/true, compileOptions.generateDebugInfo, + memoryModelName(compileOptions.memoryModel)); + auto defaultLibFile = mergeWithDefaultLibPath(getDefaultLibPath(), #ifdef WIN32 - std::string(DEFAULT_LIB_DIR "/dll/") + defaultLibBuildDir + "/" DEFAULT_LIB_NAME ".dll" + defaultLibSubDir + "/" DEFAULT_LIB_NAME ".dll" #else - std::string(DEFAULT_LIB_DIR "/dll/") + defaultLibBuildDir + "/lib" DEFAULT_LIB_NAME ".so" + defaultLibSubDir + "/lib" DEFAULT_LIB_NAME ".so" #endif - )); + ); + + // Named here rather than left to the loader, for the reason exe.cpp checks the link + // directory: a model that has not been built otherwise surfaces as the platform's + // "module could not be found", which says nothing about which model is missing. No + // fallback to another model's build - the wrong one loads and then misbehaves at run + // time, which is far harder to diagnose than a file that is not there. + if (!defaultLibFile.empty() && !llvm::sys::fs::exists(defaultLibFile)) + { + llvm::WithColor::error(llvm::errs(), "tslang") + << "no default library built for -mm=" << memoryModelName(compileOptions.memoryModel) + << ": " << defaultLibFile << " does not exist. Build it (see the default-lib build " + << "scripts), or compile with --no-default-lib.\n"; + return -1; + } + + clSharedLibs.push_back(defaultLibFile); } // add default libs in case they are not part of options @@ -323,7 +408,7 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile }); std::string pathTypeScriptLib("../lib/" LIB_NAME "TypeScriptRuntime." LIB_EXT); - if (!disableGC.getValue() && !hasTypeScriptRuntime) + if (compileOptions.needsGCRuntime() && !hasTypeScriptRuntime) { auto absPath3 = makeAbsolutePath(mergeWithDefaultLibPath(getTslangLibPath(), LIB_NAME "TypeScriptRuntime." LIB_EXT)); if (absPath3.empty()) @@ -337,7 +422,7 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile { /* llvm::WithColor::error(llvm::errs(), "tslang") << "JIT initialization failed. Missing GC library. Did you forget to provide it via " - "'--shared-libs=" LIB_NAME "TypeScriptRuntime." LIB_EXT "'? or you can switch it off by using '-nogc'\n"; + "'--shared-libs=" LIB_NAME "TypeScriptRuntime." LIB_EXT "'? or you can switch it off by using '-mm=none'\n"; return -1; */ } @@ -382,6 +467,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile addSym("calloc", (void*)&calloc); addSym("memset", (void*)&memset); addSym("memcpy", (void*)&memcpy); + // see jitAssertFailed above: bound to ucrtbase this is a modal message box, which an + // unattended run cannot answer + addSym("_assert", (void*)&jitAssertFailed); #ifdef _WIN64 // C++ EH: bind the JIT'd module's personality to our static CRT and route // throws through the shim that fixes up the throw-site image base (see @@ -411,7 +499,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile auto maybeEngine = mlir::ExecutionEngine::create(module, engineOptions); if (!maybeEngine) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to construct an execution engine, error: " << maybeEngine.takeError() << "\n"; + auto err = maybeEngine.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to construct an execution engine, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } auto &engine = maybeEngine.get(); @@ -419,7 +509,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile auto expectedFPtr = engine->lookup(mainFuncName); if (!expectedFPtr) { - llvm::WithColor::error(llvm::errs(), "tslang") << expectedFPtr.takeError(); + auto err = expectedFPtr.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << err; + llvm::consumeError(std::move(err)); return -1; } @@ -457,6 +549,20 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile } } + // Under `-mm=gc` the GC pass puts a `GC_enable_threads` call in the entry point, next to + // GC_init: the async runtime resumes coroutines on a thread pool, and the collector must be + // told it is multi-threaded before that (see AsyncGCThreads.h). The runtime that defines it is + // TypeScriptRuntime.dll, which a JIT run only has if it was passed with `--shared-libs` - and + // without it EVERY `gc` program fails to materialize, async or not, because the call is in + // main. So stand the symbol in when nothing else provides it. + // + // Asked here, after the shared libraries are loaded and before anything is defined, because + // the answer decides whether to shadow a real definition: SearchForAddressOfSymbol sees the + // export tables of everything loaded so far, which is exactly what the JIT's process generator + // will see. + auto needsGCEnableThreadsStandIn = + llvm::sys::DynamicLibrary::SearchForAddressOfSymbol("GC_enable_threads") == nullptr; + auto llvmContext = std::make_unique(); auto llvmModule = mlir::translateModuleToLLVMIR(module, *llvmContext); if (!llvmModule) @@ -468,7 +574,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile auto tmBuilderOrError = llvm::orc::JITTargetMachineBuilder::detectHost(); if (!tmBuilderOrError) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to create a JITTargetMachineBuilder for the host, error: " << tmBuilderOrError.takeError() << "\n"; + auto err = tmBuilderOrError.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to create a JITTargetMachineBuilder for the host, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } @@ -480,7 +588,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile auto tmOrError = tmBuilderOrError->createTargetMachine(); if (!tmOrError) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to create a TargetMachine for the host, error: " << tmOrError.takeError() << "\n"; + auto err = tmOrError.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to create a TargetMachine for the host, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } @@ -489,7 +599,8 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile if (auto err = optPipeline(llvmModule.get())) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to optimize LLVM IR, error: " << std::move(err) << "\n"; + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to optimize LLVM IR, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } @@ -524,7 +635,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile .create(); if (!maybeJit) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to construct the JIT engine, error: " << maybeJit.takeError() << "\n"; + auto err = maybeJit.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to construct the JIT engine, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } @@ -535,7 +648,9 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile auto generator = llvm::orc::DynamicLibrarySearchGenerator::GetForCurrentProcess(jit->getDataLayout().getGlobalPrefix()); if (!generator) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to create a process symbol generator, error: " << generator.takeError() << "\n"; + auto err = generator.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to create a process symbol generator, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } @@ -561,15 +676,28 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile addOverride("calloc", (void *)&calloc); addOverride("memset", (void *)&memset); addOverride("memcpy", (void *)&memcpy); + // see jitAssertFailed above: bound to ucrtbase this is a modal message box, which an + // unattended run cannot answer + addOverride("_assert", (void *)&jitAssertFailed); #ifdef _WIN64 // C++ EH: same-CRT personality, and throws routed through the shim that // fixes up the throw-site image base (see jitCxxThrowException above) addOverride("__CxxFrameHandler3", (void *)&__CxxFrameHandler3); addOverride("_CxxThrowException", (void *)&jitCxxThrowException); #endif + // The stand-in decided above. It goes here rather than through + // DynamicLibrary::AddSymbol because the process generator resolves from export tables + // only - an explicitly added symbol is found by SearchForAddressOfSymbol but not by the + // generator, which is why adding it there looked right and changed nothing. + if (needsGCEnableThreadsStandIn) + { + addOverride("GC_enable_threads", (void *)&jitEnableGCThreads); + } + if (auto err = jit->getMainJITDylib().define(llvm::orc::absoluteSymbols(std::move(crtOverrides)))) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to define CRT overrides, error: " << std::move(err) << "\n"; + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to define CRT overrides, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } } @@ -577,14 +705,16 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile if (auto err = jit->addIRModule(llvm::orc::ThreadSafeModule(std::move(llvmModule), std::move(llvmContext)))) { - llvm::WithColor::error(llvm::errs(), "tslang") << "failed to add the module to the JIT engine, error: " << std::move(err) << "\n"; + llvm::WithColor::error(llvm::errs(), "tslang") << "failed to add the module to the JIT engine, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } // run platform initializers (llvm.global_ctors etc.) if (auto err = jit->initialize(jit->getMainJITDylib())) { - llvm::WithColor::error(llvm::errs(), "tslang") << "JIT initialization failed, error: " << std::move(err) << "\n"; + llvm::WithColor::error(llvm::errs(), "tslang") << "JIT initialization failed, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } @@ -592,7 +722,13 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile auto sym = jit->lookup(name); if (!sym) { - llvm::WithColor::error(llvm::errs(), "tslang") << "JIT invocation failed, error: " << sym.takeError() << "\n"; + // Streaming an Error only logs it - the payload survives, and ~Error then trips + // fatalUncheckedError, turning a plain "no such symbol" into an abort with a crash + // backtrace wherever LLVM_ENABLE_ABI_BREAKING_CHECKS is on (i.e. debug builds). + // consumeError takes the payload; the message itself is unchanged. + auto err = sym.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "JIT invocation failed, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } diff --git a/tslang/tslang/opts.cpp b/tslang/tslang/opts.cpp index 43a6398cb..9da418583 100644 --- a/tslang/tslang/opts.cpp +++ b/tslang/tslang/opts.cpp @@ -15,7 +15,7 @@ namespace cl = llvm::cl; extern cl::opt inputFilename; extern cl::opt emitAction; -extern cl::opt disableGC; +extern cl::opt memoryModelOpt; extern cl::opt disableWarnings; extern cl::opt generateDebugInfo; extern cl::opt lldbDebugInfo; @@ -25,6 +25,7 @@ extern cl::opt enableBuiltins; extern cl::opt noDefaultLib; extern cl::opt outputFilename; extern cl::opt appendGCtorsToMethod; +extern cl::opt entryPoint; extern cl::opt strictNullChecks; extern cl::opt embedExportDeclarationsAction; extern cl::opt enableFastMath; @@ -42,7 +43,7 @@ CompileOptions prepareOptions() CompileOptions compileOptions; compileOptions.isJit = emitAction.getValue() == Action::RunJIT; - compileOptions.disableGC = disableGC.getValue(); + compileOptions.memoryModel = memoryModelOpt.getValue(); compileOptions.enableBuiltins = enableBuiltins.getValue(); compileOptions.noDefaultLib = noDefaultLib.getValue(); compileOptions.disableWarnings = disableWarnings.getValue(); @@ -56,6 +57,10 @@ CompileOptions prepareOptions() compileOptions.sizeBits = 32; compileOptions.isExecutable = emitAction == Action::BuildExe; compileOptions.isDLL = emitAction == Action::BuildDll; + // A DLL never gets one, whatever was asked for: its root initialization runs from the + // global constructors and there is no program here to enter. + compileOptions.generateEntryPoint = !compileOptions.isDLL && + (compileOptions.isJit || compileOptions.isExecutable || entryPoint.getValue()); compileOptions.appendGCtorsToMethod = appendGCtorsToMethod.getValue(); compileOptions.strictNullChecks = strictNullChecks.getValue(); compileOptions.enableFastMath = enableFastMath.getValue(); diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index 15a103494..50640d166 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -75,8 +75,8 @@ extern cl::opt emitAction; extern cl::opt enableOpt; extern cl::opt optLevel; extern cl::opt sizeLevel; -extern cl::opt disableGC; extern cl::opt disableWarnings; +extern cl::opt verifyOwnership; int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir::OwningOpRef &module, CompileOptions &compileOptions) { @@ -92,6 +92,11 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: bool isLoweringToAffine = emitAction >= Action::DumpMLIRAffine; bool isLoweringToLLVM = emitAction >= Action::DumpMLIRLLVM; + // Before anything is lowered, and in every memory model: the ops it removes erase on the + // way to LLVM under `gc` and `none` anyway, so doing this here keeps the IR the same shape + // in all three and keeps the ownership verifier checking one thing rather than two. + pm.addPass(mlir::typescript::createOwnedReturnConsumptionPass(compileOptions)); + if (isLoweringToAffine) { pm.addPass(mlir::createCanonicalizerPass()); @@ -124,6 +129,14 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: optPM.addPass(mlir::typescript::createRelocateConstantPass()); #endif + // Ahead of the optimisation passes, so it checks what MLIRGen and the affine lowering + // actually produced rather than what the inliner left of it, and after the lowering, + // because that is what turns unwind paths into ordinary CFG edges. + if (verifyOwnership) + { + pm.nest().addPass(mlir::typescript::createOwnershipVerifierPass()); + } + #ifdef ENABLE_OPT_PASSES if (enableOpt) { @@ -158,7 +171,7 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: pm.addPass(mlir::LLVM::createDIScopeForLLVMFuncOpPass()); } - if (!disableGC) + if (compileOptions.needsGCRuntime()) { pm.addPass(mlir::typescript::createGCPass(compileOptions)); } diff --git a/tslang/tslang/tslang.cpp b/tslang/tslang/tslang.cpp index 2db44d870..bf788f9d9 100644 --- a/tslang/tslang/tslang.cpp +++ b/tslang/tslang/tslang.cpp @@ -40,6 +40,12 @@ #include "TypeScript/DataStructs.h" #include "TypeScript/Defines.h" +#ifdef _MSC_VER +#include +#include +#include +#endif + #define DEBUG_TYPE "tslang" namespace cl = llvm::cl; @@ -114,8 +120,13 @@ cl::opt printStackTrace{"print-stack-trace", cl::Hidden, cl::desc("Print s // cl::opt targetTriple("mtriple", cl::desc("Override target triple for module")); -cl::opt disableGC("nogc", cl::desc("Disable Garbage collection"), cl::cat(TypeScriptCompilerCategory)); +cl::opt memoryModelOpt("mm", cl::desc("Memory management of compiled code"), + cl::values(clEnumValN(MemoryModelGC, "gc", "garbage collection (default)")), + cl::values(clEnumValN(MemoryModelRC, "rc", "reference counting, no collector (in development; cycles and anything the counts miss leak)")), + cl::values(clEnumValN(MemoryModelNone, "none", "no reclamation, leak everything")), + cl::init(MemoryModelGC), cl::cat(TypeScriptCompilerCategory)); cl::opt disableWarnings("nowarn", cl::desc("Disable Warnings"), cl::cat(TypeScriptCompilerCategory)); +cl::opt verifyOwnership("verify-ownership", cl::desc("Check that every slot taking a reference gives it back on every path out of the function, unwind paths included"), cl::cat(TypeScriptCompilerCategory)); cl::opt generateDebugInfo("di", cl::desc("Generate Debug Infomation"), cl::cat(TypeScriptCompilerCategory)); cl::opt lldbDebugInfo("lldb", cl::desc("Debug Infomation for LLDB"), cl::cat(TypeScriptCompilerCategory)); cl::opt exportAction("export", cl::desc("Export Symbols. (Useful to compile the same code into 'lib' (static library) and/or 'dll/so' (dynamic library)) "), @@ -135,6 +146,7 @@ cl::list objs{"obj", cl::desc("Object files to link statically. (us cl::opt noDefaultLib("no-default-lib", cl::desc("Disable loading default lib"), cl::init(false), cl::cat(TypeScriptCompilerCategory)); cl::opt enableBuiltins("builtins", cl::desc("Builtin functionality (needed if Default lib is not provided)"), cl::init(true), cl::cat(TypeScriptCompilerCategory)); +cl::opt entryPoint("entry-point", cl::desc("This file holds the program's entry point, so give it a 'main' even when its root only declares and initializes variables. Implied by --emit=jit and --emit=exe; pass it for the program's own file when building with --emit=obj and linking yourself, and leave it off for the libraries linked beside it"), cl::init(false), cl::cat(TypeScriptCompilerCategory)); cl::opt appendGCtorsToMethod("gctors-as-method", cl::desc("Creeate method (" MLIR_GCTORS ") to initialize Static Objects instead of Global Constructors (gctors)"), cl::init(false), cl::cat(TypeScriptCompilerCategory)); cl::opt strictNullChecks("strict-null-checks", cl::desc("Strict Null Checks"), cl::init(true), cl::cat(TypeScriptCompilerCategory)); @@ -147,8 +159,17 @@ cl::opt newCMakeFolder("cmake", cl::desc("New CMake Project"), cl::cat(Typ cl::opt installDefaultLibCmd("install-default-lib", cl::desc("Install Default Library. use default-lib-path to provide path where to install the lib"), cl::cat(TypeScriptCompilerCategory)); static void TslangPrintVersion(llvm::raw_ostream &OS) { - OS << "TypeScript Native Compiler (https://github.com/ASDAlexander77/TypeScriptCompiler):" << '\n'; - OS << " TySC version " << TSLANG_PACKAGE_VERSION << '\n' << '\n'; + OS << "TypeScript Compiler (https://github.com/ASDAlexander77/TypeScriptCompiler):" << '\n'; + + llvm::StringRef packageVersion = TSLANG_PACKAGE_VERSION; + if (packageVersion.empty() || packageVersion == "0.0.0-not-set") + { + OS << " tslang version (commit " << TSLANG_GIT_COMMIT_HASH << ")" << '\n' << '\n'; + } + else + { + OS << " tslang version " << packageVersion << '\n' << '\n'; + } cl::PrintVersionMessage(); } @@ -259,6 +280,18 @@ int main(int argc, char **argv) _CrtSetReportFile( _CRT_ASSERT, _CRTDBG_FILE_STDERR ); #endif +#ifdef _MSC_VER + // Neither the CRT nor Windows may stop and ask. A modal dialog in an unattended run is not + // a failure but a hang - whatever raised it waits forever on a window nobody is there to + // close - and the compiler is run unattended far more often than not. `_CrtSetReportMode` + // above only covers the debug CRT, so the release build needs `_set_error_mode` to send a + // runtime error report to stderr; `SetErrorMode` is the same thought one layer out, for a + // program that faults rather than reports: die, and let the caller see the exit code. + // (The JIT'd program's own `assert` is handled separately, in jit.cpp.) + _set_error_mode(_OUT_TO_STDERR); + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); +#endif + // version printer cl::SetVersionPrinter(TslangPrintVersion); diff --git a/tslang/tslang/vscode.cpp b/tslang/tslang/vscode.cpp index c4efa546c..ad16369d3 100644 --- a/tslang/tslang/vscode.cpp +++ b/tslang/tslang/vscode.cpp @@ -74,8 +74,38 @@ int createVSCodeFolder(int argc, char **argv) StringMap vals; vals["PROJECT"] = projectName; + // set common params + SmallVector args(argv, argv + 1); + auto driverPath = getExecutablePath(args[0]); + + SmallVector appPath{}; + appPath.append(driverPath.begin(), driverPath.end()); + path::remove_filename(appPath); + + auto tslangCmd = fixpath(driverPath, appPath); + auto gcLibPath = fixpath(getGCLibPath(), appPath); + auto llvmLibPath = fixpath(getLLVMLibPath(), appPath); + auto tslangLibPath = fixpath(getTslangLibPath(), appPath); + auto defaultLibPath = fixpath(getDefaultLibPath(), appPath); + // hint for finding tslang app (same logic as in createVSCodeFolder) + auto tslangAppPath = fixpath(string(appPath.begin(), appPath.end()), appPath); + + vals["TSLANG_CMD"] = tslangCmd; + vals["GC_LIB_PATH"] = gcLibPath; + vals["LLVM_LIB_PATH"] = llvmLibPath; + vals["TSLANG_LIB_PATH"] = tslangLibPath; + vals["DEFAULT_LIB_PATH"] = defaultLibPath; + vals["TSLANG_APP_PATH"] = tslangAppPath; + StringRef tsconfig(TSCONFIG_JSON_DATA); SmallString<128> result; + + SmallString<128> projectFileName; + projectFileName.append("[\""); + projectFileName.append(projectName); + projectFileName.append(".ts\"]"); + vals["INCLUDE"] = projectFileName; // default include + substitute(tsconfig, vals, result); if (auto error_code = create_file_base("tsconfig.json", result.str())) @@ -90,16 +120,16 @@ int createVSCodeFolder(int argc, char **argv) return -1; } - // node_modules - if (auto error_code = fs::create_directories(NODE_MODULE_TSLANG_PATH)) + // types folder + if (auto error_code = fs::create_directories(TYPES_TSLANG_PATH)) { - WithColor::error(errs(), "tslang") << "Could not create folder/directory '" << NODE_MODULE_TSLANG_PATH << "' : " << error_code.message() << "\n"; + WithColor::error(errs(), "tslang") << "Could not create folder/directory '" << TYPES_TSLANG_PATH << "' : " << error_code.message() << "\n"; return -1; } - if (auto error_code = fs::set_current_path(NODE_MODULE_TSLANG_PATH)) + if (auto error_code = fs::set_current_path(TYPES_TSLANG_PATH)) { - WithColor::error(errs(), "tslang") << "Can't open folder/directory '" << NODE_MODULE_TSLANG_PATH << "' : " << error_code.message() << "\n"; + WithColor::error(errs(), "tslang") << "Can't open folder/directory '" << TYPES_TSLANG_PATH << "' : " << error_code.message() << "\n"; return -1; } @@ -132,27 +162,6 @@ int createVSCodeFolder(int argc, char **argv) return -1; } - // set params - - SmallVector args(argv, argv + 1); - auto driverPath = getExecutablePath(args[0]); - - SmallVector appPath{}; - appPath.append(driverPath.begin(), driverPath.end()); - path::remove_filename(appPath); - - auto tslangCmd = fixpath(driverPath, appPath); - auto gcLibPath = fixpath(getGCLibPath(), appPath); - auto llvmLibPath = fixpath(getLLVMLibPath(), appPath); - auto tslangLibPath = fixpath(getTslangLibPath(), appPath); - auto defaultLibPath = fixpath(getDefaultLibPath(), appPath); - - vals["TSLANG_CMD"] = tslangCmd; - vals["GC_LIB_PATH"] = gcLibPath; - vals["LLVM_LIB_PATH"] = llvmLibPath; - vals["TSLANG_LIB_PATH"] = tslangLibPath; - vals["DEFAULT_LIB_PATH"] = defaultLibPath; - StringRef tasks(TASKS_JSON_DATA); SmallString<128> resultTasks; substitute(tasks, vals, resultTasks);