Skip to content

Commit 1fd26cd

Browse files
Add captures-cast-to-interface test; document imported-object cast bugs
Closes out the two test-matrix gaps from docs/interface-vtable-simplification-design.md §7: - Captures-bearing object-literal method cast to an interface (00interface_captures.ts): confirms the constant-vtable path (§3) handles captures correctly - one shared function symbol per literal expression, per-instance state in a separate .captured field, no per-cast heap patch. - Casting an imported (cross-module) object value to an interface inside the importing module: attempting to write this test surfaced two separate pre-existing bugs, unrelated to this arc's changes, that make the scenario not work at all today regardless of how the value is exported - an mlir::cast<TupleType> assertion (MLIRGenInterfaces.cpp:312) when the export has no type annotation and degrades to a bare `object` type on reimport, and an llvm_unreachable("review usage") in the generic cast lowering (CastLogicHelper.h:765) when the export is given an explicit structural type instead. No test added (would just crash); both findings are documented in the design doc as a distinct, deeper bug area for future investigation, out of scope for this arc. 720/720 full suite green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1db740c commit 1fd26cd

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

tslang/docs/interface-vtable-simplification-design.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,69 @@ Known risks: two-pass compilation (`Stages::Discovering`) — the side table
333333
must be populated consistently in whichever pass builds the vtable initializer;
334334
and `@dllimport` type reconstruction must keep *missing* from the side table
335335
(never a stale hit) so imported types deterministically take the fallback.
336+
337+
### Test matrix follow-up (post-PR2, 2026-07-19)
338+
339+
**Captures-bearing literal cast to an interface**: was genuinely uncovered:
340+
neither `00funcs_capture.ts` (captures an outer var in an object-literal
341+
method, but never cast to an interface) nor any of `00interface_object.ts` /
342+
`00interface_global_method.ts` / `00interface_object5.ts` (cast to an
343+
interface, but capture-free) combined both properties. Added
344+
`00interface_captures.ts`. Works correctly, confirming §3's PR2
345+
implementation note that captures need no fallback: `--emit=mlir` shows the
346+
vtable slot gets the constant `SymbolRefOp` (one lifted function shared by
347+
every `make(x)` call) and exactly one `ts.New` (boxing the literal, which
348+
carries the per-instance `.captured` field the shared function reads via
349+
`this`) — no second heap allocation for a patched vtable. 720/720 with this
350+
test added.
351+
352+
**The imported-object-type fallback is not just untested - it's currently
353+
broken, two different ways, independent of this arc's changes.** Attempting
354+
to actually write the cross-module test surfaced two separate PRE-EXISTING
355+
bugs (reproduced on `main@1db740c6`, i.e. before this arc's changes were ever
356+
written into that call path in a way that matters here — both crash sites
357+
predate PR2 and are unrelated to `objectLiteralMethodSymbolsMap`):
358+
359+
1. `export var counterObj = { count: 0, inc() {...} };` (no explicit type
360+
annotation, inferred boxed `ObjectType`) exports its declaration as `let
361+
counterObj : object;` — the cross-module declaration-serialization
362+
mechanism has no way to write out an inferred object-literal's structural
363+
shape, degrading to the bare `object` type. Reimporting and casting that
364+
to an interface in the importer hits `mlir::cast<mlir_ts::TupleType>`
365+
asserting false at `MLIRGenInterfaces.cpp:312`
366+
(`mlirGenObjectVirtualTableDefinitionForInterface`), because
367+
`objectType.getStorageType()` for the reconstructed bare-`object` type
368+
isn't a `TupleType`/`ObjectStorageType` at all. Stack confirmed via
369+
WinDbgX (`WinDbgX.exe -pv -p <pid> -c ".dump /ma <path>" -c "qd"` attached
370+
to the process while it was blocked on the assert's message box, then
371+
`~*k` on the dump) - crash path: `MLIRGenCast.cpp:1621/1672`
372+
(`castObjectToInterface`) → `MLIRGenInterfaces.cpp:193`
373+
(`mlirGenCreateInterfaceVTableForObject`) → `:312`.
374+
2. Giving the export an explicit structural type annotation
375+
(`export var counterObj: { count: number; inc(): void } = {...}`) avoids
376+
the `object`-degradation (the declaration now serializes as a real tuple
377+
type, `let counterObj : [count:number, inc:() => void];`) but the literal
378+
no longer gets boxed as `ObjectType` at all (plain tuple instead) — and
379+
casting *that* cross-module tuple value to a method-bearing interface in
380+
the importer hits `llvm_unreachable("review usage")` at
381+
`CastLogicHelper.h:765`, a pre-existing dead/unimplemented branch in the
382+
LOWERING-level cast dispatcher (`castLLVMTypes`'s "value to ref of value"
383+
case).
384+
385+
Neither is caused by or related to `objectLiteralMethodSymbolsMap`/§3's
386+
side table — both crash before any vtable-slot content is decided, in the
387+
object-type reconstruction and generic-cast layers respectively. This means
388+
casting a cross-module method-bearing object VALUE to an interface inside
389+
the importing module does not currently work at all, regardless of how it's
390+
exported. **Test not added** (a test that's expected to crash doesn't
391+
belong in the regression suite); the attempt and both crash sites are
392+
recorded here instead. This is a distinct, deeper bug area (object-literal
393+
type export/reimport across `@dllimport` boundaries, and the generic
394+
cross-type-system cast lowering) than the vtable-slot work in §3-§5, and
395+
is a candidate for its own separate investigation if it becomes worth
396+
fixing - not part of this arc. `export_object_literal_with_interface.ts`'s
397+
existing pattern (declare the export *already typed as the interface* at
398+
its definition site, `export var counter: Counter = {...}`, so the cast
399+
happens in the exporting module where a local `funcOp` genuinely exists)
400+
remains the only currently-working way to share a method-bearing object
401+
across modules, and is unaffected by any of the above.

tslang/test/tester/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ add_test(NAME test-compile-00-interface-partial COMMAND test-runner "${PROJECT_S
286286
add_test(NAME test-compile-00-interface-optional COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional.ts")
287287
add_test(NAME test-compile-00-interface-optional-cast-order COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional_cast_order.ts")
288288
add_test(NAME test-compile-00-interface-function-typed-field COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_function_typed_field.ts")
289+
add_test(NAME test-compile-00-interface-captures COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_captures.ts")
289290
add_test(NAME test-compile-00-interface-generic COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_generic.ts")
290291
add_test(NAME test-compile-00-interface-new COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_new.ts")
291292
add_test(NAME test-compile-00-interface-indexer COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_indexer.ts")
@@ -638,6 +639,7 @@ add_test(NAME test-jit-00-interface-partial COMMAND test-runner -jit "${PROJECT_
638639
add_test(NAME test-jit-00-interface-optional COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional.ts")
639640
add_test(NAME test-jit-00-interface-optional-cast-order COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional_cast_order.ts")
640641
add_test(NAME test-jit-00-interface-function-typed-field COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_function_typed_field.ts")
642+
add_test(NAME test-jit-00-interface-captures COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_captures.ts")
641643
add_test(NAME test-jit-00-interface-generic COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_generic.ts")
642644
add_test(NAME test-jit-00-interface-new COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_new.ts")
643645
add_test(NAME test-jit-00-interface-indexer COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_indexer.ts")
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// regression test: an object-literal method that captures an outer variable,
2+
// cast to an interface and called through the interface reference. Confirms
3+
// the constant-vtable optimization (see
4+
// docs/interface-vtable-simplification-design.md §3) works correctly for
5+
// captures-bearing methods, not just capture-free ones: the vtable slot gets
6+
// a compile-time-constant SymbolRefOp (the lifted method is per-literal-
7+
// expression, shared by every instance), while each instance's captured data
8+
// lives in a separate per-object `.captured` field the method reads via
9+
// `this` - so two independently-created instances must not share captured
10+
// state.
11+
12+
interface Getter {
13+
get(): number;
14+
}
15+
16+
function make(x: number): Getter {
17+
return {
18+
get() {
19+
return x;
20+
}
21+
};
22+
}
23+
24+
function main() {
25+
const g1: Getter = make(42);
26+
const g2: Getter = make(100);
27+
28+
assert(g1.get() == 42);
29+
assert(g2.get() == 100);
30+
// the two instances must not share captured state
31+
assert(g1.get() == 42);
32+
33+
print("done.");
34+
}

0 commit comments

Comments
 (0)