From e5fe55cda4f8ebe078967900b289e89606e16a4d Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sun, 19 Jul 2026 00:04:30 +0100 Subject: [PATCH] Heap-allocate patched interface vtable so it survives in a global __cctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-level binding whose declared type is an interface, initialized from an object literal that has a method (const c: Counter = { count: 0, inc(){...} }), crashed the JIT with 0xC0000005 on the first field/method access. Root cause: casting the (boxed) object literal to the interface builds a per-object vtable patched with the method's function pointer (mlirGenCreateInterfaceVTableForObject). That vtable was a stack VariableOp (alloca). For a local binding the alloca outlives its uses, but a global binding's initializer lowers to a __cctor function -- the alloca dangled once the __cctor returned, leaving the interface's vtable pointer referencing freed stack memory. Fix: heap-allocate the patched vtable (NewOp + StoreOp + CastOp), putting it on the same footing as the object it describes (already NewOp-allocated). The method-less interface path is unaffected -- it points NewInterface directly at the shared global vtable, no per-object copy. Regression test 00interface_global_method.ts (also exercises two independent same-shape globals to confirm their vtables don't alias). Full suite green: 354/354 JIT + 358/358 AOT. The cross-module (-shared) method-bearing interface case is a separate, still-open issue in the shared-lib symbol-resolution subsystem, documented in docs/object-literal-boxing-design.md §6. Co-Authored-By: Claude Fable 5 --- tslang/docs/object-literal-boxing-design.md | 22 ++++++++++ tslang/lib/TypeScript/MLIRGenInterfaces.cpp | 13 ++++-- tslang/test/tester/CMakeLists.txt | 2 + .../tester/tests/00interface_global_method.ts | 40 +++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tslang/test/tester/tests/00interface_global_method.ts diff --git a/tslang/docs/object-literal-boxing-design.md b/tslang/docs/object-literal-boxing-design.md index eb12e5a47..dcda767a7 100644 --- a/tslang/docs/object-literal-boxing-design.md +++ b/tslang/docs/object-literal-boxing-design.md @@ -226,6 +226,28 @@ Things that consume the literal's *value type* and must tolerate ## 6. Risks / open items +- **FIXED (2026-07-19): global interface from a method-bearing literal**. A + top-level binding whose declared type is an interface, initialized from an + object literal with a method (`const c: Counter = { count: 0, inc(){...} }`), + crashed the JIT (0xC0000005) on first access. Casting the literal to the + interface (`mlirGenCreateInterfaceVTableForObject`) builds a *per-object* + vtable patched with the method's function pointer; that vtable was a stack + `VariableOp` (alloca). A local binding's alloca outlives its uses, but a + global binding's initializer lowers to a `__cctor` function, so the alloca + dangled once `__cctor` returned and the interface's vtable pointer + referenced freed stack memory. Fix: heap-allocate the patched vtable + (`NewOp` + `StoreOp` + `CastOp`), same footing as the object it describes. + Regression test `00interface_global_method.ts`. Note the method-*less* + interface case (`Point`) was always fine — it points `NewInterface` + directly at the shared global vtable, no per-object patched copy. +- **STILL OPEN: cross-module (`-shared`) method-bearing interface**. Exporting + such a global and reading it from an importing module still crashes the JIT + (null function-pointer call in the importer's `__mlir_gctors`); the + `-shared` AOT path has its own separate issues too. This is a distinct + problem in the shared-lib DLL-load / `gctors-as-method` symbol-resolution + subsystem, not the vtable-lifetime bug fixed above (which was + single-module). Reproduces via the `export/import_object_literal_with_interface.ts` + pair with an added method-bearing `export var`. Not yet root-caused. - **Annotated tuple types re-open a small value-semantics window**: after gap 3, `const x: { m(): void } = { m() {} }` copies the object into a tuple at the annotation boundary, losing aliasing for that binding. The diff --git a/tslang/lib/TypeScript/MLIRGenInterfaces.cpp b/tslang/lib/TypeScript/MLIRGenInterfaces.cpp index 9d40543dc..3859ad205 100644 --- a/tslang/lib/TypeScript/MLIRGenInterfaces.cpp +++ b/tslang/lib/TypeScript/MLIRGenInterfaces.cpp @@ -213,11 +213,18 @@ namespace mlirgen } // match VTable - // 1) clone vtable + // 1) clone vtable onto the GC heap (NOT a stack VariableOp): this per-object + // patched vtable is pointed at by the resulting interface value, and that + // interface can be stored into a global whose initializer lowers to a + // __cctor function -- a stack alloca would dangle once the __cctor returns, + // crashing on the first field/method access through the interface. Heap + // allocation puts the vtable on the same footing as the object itself + // (already `NewOp`-allocated). See docs/object-literal-boxing-design.md. auto vtableType = mlir::cast(mlir::cast(globalVTableRefValue.getType()).getElementType()); auto valueVTable = builder.create(location, vtableType, globalVTableRefValue); - auto varVTable = builder.create(location, globalVTableRefValue.getType(), valueVTable, - builder.getBoolAttr(false), builder.getIndexAttr(0)); + auto heapVTable = builder.create(location, mlir_ts::ValueRefType::get(vtableType), builder.getBoolAttr(false)); + builder.create(location, valueVTable, heapVTable); + auto varVTable = builder.create(location, globalVTableRefValue.getType(), heapVTable); for (auto& method : newInterfacePtr->methods) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index cc7ae3218..e98769902 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -280,6 +280,7 @@ add_test(NAME test-compile-00-interface-object-2 COMMAND test-runner "${PROJECT_ add_test(NAME test-compile-00-interface-object-3 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_object3.ts") add_test(NAME test-compile-00-interface-object-4 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_object4.ts") add_test(NAME test-compile-00-interface-object-5 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_object5.ts") +add_test(NAME test-compile-00-interface-global-method COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_global_method.ts") add_test(NAME test-compile-00-interface-conjunction COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_conjunction.ts") add_test(NAME test-compile-00-interface-partial COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_partial.ts") add_test(NAME test-compile-00-interface-optional COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional.ts") @@ -629,6 +630,7 @@ add_test(NAME test-jit-00-interface-object-2 COMMAND test-runner -jit "${PROJECT add_test(NAME test-jit-00-interface-object-3 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_object3.ts") add_test(NAME test-jit-00-interface-object-4 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_object4.ts") add_test(NAME test-jit-00-interface-object-5 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_object5.ts") +add_test(NAME test-jit-00-interface-global-method COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_global_method.ts") add_test(NAME test-jit-00-interface-conjunction COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_conjunction.ts") add_test(NAME test-jit-00-interface-partial COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_partial.ts") add_test(NAME test-jit-00-interface-optional COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional.ts") diff --git a/tslang/test/tester/tests/00interface_global_method.ts b/tslang/test/tester/tests/00interface_global_method.ts new file mode 100644 index 000000000..b84b2dfb8 --- /dev/null +++ b/tslang/test/tester/tests/00interface_global_method.ts @@ -0,0 +1,40 @@ +// regression test: a top-level (global) binding whose declared type is an interface, +// initialized from an object literal that has a method, used to crash the JIT with +// 0xC0000005 on the first field/method access. +// Root cause: casting the (boxed) object literal to the interface builds a per-object +// vtable patched with the method's function pointer; that vtable was a stack VariableOp +// (alloca). For a local binding the alloca outlives its uses, but a global binding's +// initializer lowers to a __cctor function, so the alloca dangled once the __cctor +// returned -- the interface's vtable pointer then referenced freed stack memory. +// Fix: heap-allocate the patched vtable (mlirGenCreateInterfaceVTableForObject), same +// footing as the object it describes. + +interface Counter { + count: number; + inc(): void; +} + +const counter: Counter = { + count: 0, + inc() { this.count = this.count + 1; }, +}; + +// a second same-shape global to make sure two independent per-object vtables both survive +const other: Counter = { + count: 100, + inc() { this.count = this.count + 10; }, +}; + +function main() { + assert(counter.count == 0); + counter.inc(); + counter.inc(); + assert(counter.count == 2); + + other.inc(); + assert(other.count == 110); + // the two globals must not share state + assert(counter.count == 2); + + print("done."); +}