Skip to content

An interface cast to one it does not extend gets a vtable laid out for it - #666

Merged
ASDAlexander77 merged 2 commits into
mainfrom
iface-cast-remap-vtable
Oct 23, 2026
Merged

ASDAlexander77 merged 2 commits into
mainfrom
iface-cast-remap-vtable

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Follow-up 6 of .superpowers/own-raytrace-rulings.md: an object literal re-viewed as another interface read wrong offsets. It was wider than filed: every cast from one interface to another that it does not extend kept the value's own vtable, and the target interface read it by its own slot numbers. Every model, gc included:

interface Hit { thing: number; ray: number; dist: number; }
interface HasDist { dist: number; }
const d: HasDist = h;   // d.dist printed h.thing
interface A { f(): number; g(): number; }  interface B { g(): number; }
const b: B = a;         // b.g() called a.f()

A cast to an interface the value extends worked, because the base's members come first.

Fix

  • MLIRGen (castInterfaceToInterface): for each slot of the target's vtable, find the slot of the source's that holds the same member. If that is every slot in place (an extends base, or the same layout), the cast is unchanged. Otherwise the ts.Cast carries the map (__vtable_map). Lowering builds the target's vtable from the value's own one. A slot is copied as it is, so the cast is rejected when a member is a method of one interface and a field of the other, or when a field has another type. A member the source lacks reads as absent (-1). tslang has always compiled tube = ball, where Ball has no length, and structural-typing.ts relies on that.
  • Lowering: ts_remap_vtable(vtable, map, count) is generated into the module with internal linkage, so it needs no runtime-library symbol under any model, JIT or AOT. A vtable with the same content is built once and kept in a list for as long as the program runs. A cast in a loop therefore allocates nothing after the first time, and nothing has to free a vtable. The list is pushed with a compare-exchange. A null interface stays null.
  • The cast stays a ts.Cast, so the rc and own passes see it exactly as before.

Tests

  • tests/00interface_cast_unrelated.ts covers:

    • fields of an object literal, of a call's result, of an argument, through <T>, and of an element;
    • two fields in another order;
    • a class object;
    • a cast of a cast;
    • an absent optional member;
    • methods of a class object and of an object literal;
    • the second interface of a diamond extends;
    • 1000 casts in a loop.

    It runs under gc and in the rc/none corpus.

  • Tooth: with the new path turned off, gc prints 1 (thing) where 3 (dist) is right, and 1 | 1 where 2 | 20 is right for the methods.

  • Two compile errors, in interface-cast/method_field.ts and interface-cast/field_type.ts.

Not covered

Verification

  • Windows: 5377/5377.
  • Linux: 5359/5359, and the 25 interface-cast and structural-typing tests pass with MALLOC_PERTURB_=165 MALLOC_CHECK_=3.
  • The default library compiles (lib.ts, lib.win32.ts) under gc, rc, none and own.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 23, 2026 12:01
…r it

An interface value keeps the vtable it was made with, laid out for its own
interface. Cast to another interface that it does not extend - one with only
some of its members, in other places - the value was a plain ts.Cast, so the
other interface read that vtable by its own slot numbers: `HasDist.dist` read
`Hit.thing`, its first slot, and `B.g()` called `A.f()`. Every model, gc too.

The cast now carries, for each slot of the target's vtable, the slot of the
source's that holds the same member (INTERFACE_VTABLE_MAP_ATTR_NAME), and
lowering builds the target's vtable from the value's own one. A vtable with
the same content is built once and kept in a list for as long as the program
runs (ts_remap_vtable, generated into the module), so a cast in a loop
allocates nothing after the first and nothing has to free one. The cast stays
a ts.Cast, so the ownership passes see it as before. A cast to an interface
the value extends, or to one laid out the same, is unchanged.

A slot is copied as it is, so a member that is a method of one and a field
of the other, or a field of another type, is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…error

tslang has always let `tube = ball` through (a Ball having no `length`),
and structural-typing.ts and typeGuardOfFormThisMember.ts rely on it. The
remapped vtable gives such a member -1, as an absent optional one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit 0f47dd2 into main Oct 23, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the iface-cast-remap-vtable branch October 23, 2026 12:14
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…slot (#668)

An interface value was { vtable, this, type }: the third word was the
runtime type tag of what `this` points at, which rc and own release
and retain the object through. Every interface vtable is made for one
concrete type - a class's (a subclass has its own), an object
literal's - so the tag now lives in the vtable instead, and the value
is two pointers again (16 bytes, not 24).

The interface vtable header is { type tag, `.instanceOf` }
(INTERFACE_VTABLE_TYPE_SLOT 0, INTERFACE_VTABLE_INSTANCEOF_SLOT 1,
INTERFACE_VTABLE_HEADER_SLOTS 2); members follow it as before. A
class's own vtable still starts with its `.instanceOf`
(CLASS_VTABLE_INSTANCEOF_SLOT), so `instanceof` names the slot of the
vtable it reads.

- The class, object-literal and null vtable builders put the tag in
  (mlirGenInterfaceVTableTypeTag): the type's descriptor, or null when
  the type owns no heap memory, and in a null vtable.
- NewInterface no longer makes a tag. releaseViaInterfaceTag reads it
  through the vtable (a zeroed slot has no vtable, a null interface a
  null tag: both release nothing); a method bound through an interface
  takes the tag from the same vtable.
- A cast to an interface the value's type does not extend (#666) keeps
  the header: the tag is the value's, whatever it is seen through.
- The own signature pass reads interface vtables past the two-slot
  header.
- -mm=own: a record box that borrows its fields (spec 32) is released
  by a routine of its own, which the tag must name. Its vtable is the
  object literal's, shared by every record of its shape, so the
  signature pass gives such a box a copy of that vtable
  (`<vtable>.r<i>...vtbl`) whose descriptor names the borrowing
  routine (giveBorrowingRecordsTheirVTables).

DefaultLib needs no source change; it is rebuilt by the new compiler.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…ace-to-interface cast

`J | null` given to `I | null`, where `J` does not extend `I`, was a plain cast:
the value kept `J`'s vtable, and a field read through `I` took a method slot of
it for the field's offset (0xC0000005 under every model). Both are stored as
their one member, so this is now the interface-to-interface cast of `J` to `I`
(#666), as `C | null` to `I | null` is the class-to-interface cast (#665). A
null value stays null: its vtable, the null vtable of `J`, is remapped as any
other, and its methods throw.

Test 00nullable_interface_to_interface.ts (compile + jit, rc/none corpus) reads
a field before any method call; not under -mm=own, which refuses the program's
interface-returning calls with or without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
An interface J that does not extend I gets a vtable laid out for I when it
is cast to I (#666), but `J | null` given to `I | null` was a plain cast of
the union: the value kept J's vtable, and I read it by its own slot numbers
(`d.dist` read `thing`, `b.g()` called `f`). The same union given to `I`
(as `I | undefined` is, through its optional) was a plain cast too, and for
`C | null`, a class, lowering could not make the class pointer an interface.

Both unions are stored as their one member, null a null pointer in it, so
these are the cast of that member: castToNullableUnionType takes an
interface member as it takes a class one (#665), and
castToInterfaceSpecialCases casts the member of such a union. Null stays
null.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…ace-to-interface cast (#670)

`J | null` given to `I | null`, where `J` does not extend `I`, was a plain cast:
the value kept `J`'s vtable, and a field read through `I` took a method slot of
it for the field's offset (0xC0000005 under every model). Both are stored as
their one member, so this is now the interface-to-interface cast of `J` to `I`
(#666), as `C | null` to `I | null` is the class-to-interface cast (#665). A
null value stays null: its vtable, the null vtable of `J`, is remapped as any
other, and its methods throw.

Test 00nullable_interface_to_interface.ts (compile + jit, rc/none corpus) reads
a field before any method call; not under -mm=own, which refuses the program's
interface-returning calls with or without the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 23, 2026
…s the block it shows (#671)

own_facts::isView took a cast between class, union and optional types as the
same block, but not one from or to an interface. So `const b: B = a` (A to B),
`const d: HasDist = mkHit()`, `return h` into `Hit | null`, and even a cast
to a base interface (B extends A, A of B) were each a second reference own
could not prove a move or a borrow of. Every interface-to-interface cast
(#666, #669, #670) was rejected under own.

The cast keeps `this`; the vtable laid out for the other interface is
interned, nobody's block. So it is a view like the others, and own moves and
borrows through it as it does the object: a read after `[a]` moved it is
still an error.

00interface_cast_unrelated, 00interface_union_cast_unrelated,
00nullable_interface_to_interface and 00null_interface_to_interface now run
under own too. 00interface_cast_unrelated's element and loop cases no longer
move or merge `h` (own's rules, not this cast's).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant