Skip to content

A null class object, null or undefined given to an interface is a null interface whose methods throw - #665

Merged
ASDAlexander77 merged 6 commits into
mainfrom
union-class-to-interface-cast
Oct 23, 2026
Merged

ASDAlexander77 merged 6 commits into
mainfrom
union-class-to-interface-cast

Conversation

@ASDAlexander77

@ASDAlexander77 ASDAlexander77 commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

let opt: I | null = flag ? mk(5) : null (a C | null given to an I | null) was a plain ts.Cast. The class pointer was read as an interface, which the lowering couldn't build (invalid cast from '!llvm.ptr' to '!llvm.struct<(ptr, ptr, ptr)>'). This affected every memory model, gc included: the program crashed, or called through a wrong vtable into code that allocated without end. Found while fixing memory-model follow-up item 9 (#656).

What an I | null is now

I | null is stored as I, as C | null is stored as C, so giving one to the other is the ordinary class-to-interface cast. A null interface is an interface over a null this, seen through the interface's null vtable.

  • Methods: each slot is a stub that throws, as a call of an abstract method would: new Error("method is abstract"), or the message itself without the default library, which has no Error.
  • Fields: a slot holds offset 0, so reading a field reads through the null this and faults, as a null class object's field does.
  • Optional members: absent (-1).
  • .instanceOf: answers false.

These values are null interfaces:

  • a null class object cast to an interface. That cast was wrong without any union: under non-strict null a null C gave I a non-null vtable, so === null was false, and for a class that implements I the cast read the vtable through the null pointer and crashed;
  • null and undefined given to I or I | null.

An interface is tested for null, and compared, by its this: in ===/==, in its truth value, and therefore in ?., ?? and instanceof. Two interfaces over the same object are now equal. Before, interfaces were compared by vtable, so any two objects of one class were "equal".

Not covered: zeroed interface storage (an uninitialized field, say) is still null by this, but has no vtable, so a method call on it faults instead of throwing.

How it's built

  • castToInterfaceSpecialCases (the class branch) picks the vtable with a test for null; mlirGenClassInterfaceVTable is now shared with the plain cast. The interface is built over the object outside that test, so it stays a view for the ownership passes.
  • A null object's vtable, and a null/undefined cast, stay a cast of null in the IR the ownership passes see. The cast names the null vtable (__null_interface_vtable), and the lowering puts it in (CastLogicHelper::nullInterfaceVTable).
  • mlirGenNullInterfaceVTable makes one null vtable per interface and module (<I>.null..vtbl) with its stubs (<I>.null.<method>). A discovery run makes none.

Ownership passes

  • OwnedReturnConsumptionPass: claimGlobalInitializers and viewedValue look through the class read out of the union. Without that, let g: I | null = maybe() was born pointing at freed memory (rc, own), and const o: I | null = c held no count, so c = null freed what o read.
  • Signature pass: reads the class vtable through the null-test branch (17 own tests failed without this). It leaves null vtables out and doesn't let a stub's symbol open anything.
  • rc pass: leaves null vtables and stubs out of what calls can reach. A stub never returns, so it doesn't turn off the whole-module "every heap result is retained" rule that reduce callbacks depend on.
  • throw no longer disables -mm=own's "a C function of numbers destroys nothing" rule (spec 31). The exception tables name the C++ runtime's type_info vtable, a foreign global, which counted as a way to hand C a function. It is marked (__eh_type_info) and left out. Any module with a throw hit this.
  • Existing bug in mlirGenFunctionBody, fixed: a function returning nothing that is made while another function is being generated used to pick up that function's return variable. A stub's throw also no longer walks out into the scopes of the function whose cast made it.

Tests

  • tests/00nullable_class_to_interface.ts: C | null → I | null in every position (conditional, const, argument, return, assignment, global, held after the local lets go, an implements class). Runs under gc, plus rc and none (corpus).
  • own/own_nullable_class_interface.ts: the global-initializer shape, under own, rc, none and gc.
  • tests/00class_to_interface_null.ts (non-strict, own too): plain C → I with a null object, null and undefined given to I. Checks === null, ==, !, methods throwing, and identity compare.
  • tests/00null_interface_to_interface.ts: a null interface remapped by An interface cast to one it does not extend gets a vtable laid out for it #666 to an interface it doesn't extend stays null, and its methods throw. Not under own, which doesn't take interface→interface casts of an existing value yet.
  • Safe failure: every test reads a field before any method call or allocation, so a regression fails an assert instead of running away. The tooth, run one process at a time under a memory/time watchdog, failed at the first assert using 2-4 MB (AOT) or crashed early (JIT).

Verification

  • Windows ctest -C Release: 5408/5408 passed (merged with main at 144528a).
  • Linux (WSL): 5390/5390 passed.
  • Leaks: an rc loop of 2M iterations, four conversions each, stays at 4.5 MB.

Not in this PR (existing bugs)

  • Narrowing an interface to null in the right side of a === null && ... makes an invalid cast (struct -> ptr) and, on main, evaluates wrong.
  • -mm=own still rejects most I | null values made from a class, and interface→interface casts of an existing value.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 23, 2026 12:15
…face

`let opt: I | null = flag ? mk(5) : null` (`C | null` to `I | null`)
was a plain ts.Cast: the class pointer was read as an interface, which
the lowering could not make ("invalid cast from '!llvm.ptr' to
'!llvm.struct<(ptr, ptr, ptr)>'"), under every model. The program
crashed, or called through a wrong vtable into code that allocated
without end.

castToNullableUnionType now converts the class member the way
`let a: I = mk()` does (the vtable lookup is shared,
mlirGenClassInterfaceVTable), and null stays null. Only the vtable is
chosen by a test for null - it may be read through the object - and a
null object is seen through a null vtable, which is what an interface
compares to null by. The ts.NewInterface is built over the object
outside the test, so it stays a view of the union for the ownership
passes. Two of them now look through the class read out of the union:
a global's initializer (`let g: I | null = maybe()` was born pointing
at freed memory under rc and own) and a const's view
(`const o: I | null = c` was folded with no count, and `c = null`
freed what it read).

Tests read a field before any method call or allocation, so a
regression fails an assert instead of running away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`C | null` is stored as `C`, so `C | null` to `I | null` is the
class-to-interface cast of `C` to `I`: the union path now only reads
the class out of the union and makes that cast. The null handling moves
into the class-to-interface cast itself, where it was missing for a
plain `C` too (non-strict null): a null class object was paired with
its class's vtable, so the interface was not `=== null`, and for a
class naming the interface, whose vtable is read through the object,
the cast read through the null pointer and crashed. Only the vtable is
chosen by the test for null; the interface is still built over the
object outside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 and others added 4 commits October 23, 2026 13:22
…able

A class object's vtable for an interface is now chosen by a test for
null (castToInterfaceSpecialCases), so ts.NewInterface's vtable is a
ts.If's result or, by the time the signature pass runs, a block argument
its branches pass it to. The pass took that for a vtable it could not
trace and opened the interface ("reached through an interface, whose
calls are not given its facts"): 17 own tests failed. It now follows
each branch's vtable, skipping a null object's, and counts a class
vtable passed along a branch as one that only makes values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A null interface - `I | null` holding null, a null class object seen
through `I`, `null` or `undefined` given to `I` - is an interface over
a null `this`, seen through the interface's null vtable
(mlirGenNullInterfaceVTable, `<I>.null..vtbl`, one per interface and
module). Each of its methods is a stub that throws, as a call of an
abstract method would: `new Error("method is abstract")`, or the
message itself without the default library, which has no `Error`. A
field's slot is offset 0, so reading one reads through the null
`this`, as a null class object's field does; an optional member is
absent (-1); `.instanceOf` answers false.

A null class object was seen through a null vtable, a call of one of
its methods jumped to address 0, and `null` cast to an interface had
no vtable at all.

An interface is now tested for null, and compared, by its `this`
(UnaryBinLogicalOrHelper, and CastLogicHelper's interface-to-boolean),
not its vtable: a null interface has one, and two interfaces over the
same object are the same value (they were compared by vtable, so two
objects of one class were equal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The null vtable and its throwing stubs are compiler glue the ownership
passes must not read as a way an interface is implemented:

- A null interface stays a cast of null in the IR the passes see -
  `null`/`undefined` given to an interface, and a null class object's
  vtable choice - and the cast names the null vtable
  (NULL_INTERFACE_VTABLE_ATTR_NAME), which the lowering puts in
  (CastLogicHelper::nullInterfaceVTable). An `AddressOf` of it in a
  function opened the interface and lost the `reduce` callback's facts
  in the own ray tracer.
- The signature pass leaves the null vtable out of the interface
  vtables, and a stub's symbol there does not open it; the rc pass
  leaves both out of what a call through an interface or a value may
  reach - a stub returns nothing, so it does not switch off the
  whole-module "every heap result is retained" answer either.
- A stub is a function of its own: its `throw` no longer walks out into
  the scopes of the function whose cast made it, and
  mlirGenFunctionBody no longer gives a function returning nothing the
  return variable of the function being generated around it.
- A discovery run makes no null vtable; one it left without a body is
  made again.

And `throw` no longer turns off -mm=own's "a C function of numbers
destroys nothing" (spec 31): the exception tables name the C++
runtime's type_info vtable, a foreign global, which counted as a way
to hand C a function. It is marked (EH_TYPE_INFO_ATTR_NAME) and left
out. Any module with a `throw` hit it (`sqrt` then "may release").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto union-class-to-interface-cast

# Conflicts:
#	tslang/include/TypeScript/Defines.h
#	tslang/lib/TypeScript/MLIRGenCast.cpp
#	tslang/lib/TypeScript/MLIRGenImpl.h
#	tslang/lib/TypeScript/OwnershipSignaturePass.cpp
@ASDAlexander77 ASDAlexander77 changed the title A class object or null given to an interface or null becomes an interface A null class object, null or undefined given to an interface is a null interface whose methods throw Oct 23, 2026
@ASDAlexander77
ASDAlexander77 merged commit 1bfbb2e into main Oct 23, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the union-class-to-interface-cast branch October 23, 2026 14:02
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>
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