Repository navigation
A null class object, null or undefined given to an interface is a null interface whose methods throw - #665
Merged
Conversation
…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>
…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
This was referenced Oct 23, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
let opt: I | null = flag ? mk(5) : null(aC | nullgiven to anI | null) was a plaints.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 | nullis nowI | nullis stored asI, asC | nullis stored asC, so giving one to the other is the ordinary class-to-interface cast. A null interface is an interface over a nullthis, seen through the interface's null vtable.new Error("method is abstract"), or the message itself without the default library, which has noError.thisand faults, as a null class object's field does..instanceOf: answers false.These values are null interfaces:
CgaveIa non-null vtable, so=== nullwas false, and for a class thatimplements Ithe cast read the vtable through the null pointer and crashed;nullandundefinedgiven toIorI | null.An interface is tested for null, and compared, by its
this: in===/==, in its truth value, and therefore in?.,??andinstanceof. 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;mlirGenClassInterfaceVTableis 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.null/undefinedcast, 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).mlirGenNullInterfaceVTablemakes one null vtable per interface and module (<I>.null..vtbl) with its stubs (<I>.null.<method>). A discovery run makes none.Ownership passes
OwnedReturnConsumptionPass:claimGlobalInitializersandviewedValuelook through the class read out of the union. Without that,let g: I | null = maybe()was born pointing at freed memory (rc, own), andconst o: I | null = cheld no count, soc = nullfreed whatoread.reducecallbacks depend on.throwno longer disables -mm=own's "a C function of numbers destroys nothing" rule (spec 31). The exception tables name the C++ runtime'stype_infovtable, 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 athrowhit this.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'sthrowalso no longer walks out into the scopes of the function whose cast made it.Tests
tests/00nullable_class_to_interface.ts:C | null→I | nullin every position (conditional, const, argument, return, assignment, global, held after the local lets go, animplementsclass). 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): plainC→Iwith a null object,nullandundefinedgiven toI. 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.Verification
ctest -C Release: 5408/5408 passed (merged with main at 144528a).Not in this PR (existing bugs)
nullin the right side ofa === null && ...makes an invalid cast (struct -> ptr) and, on main, evaluates wrong.I | nullvalues made from a class, and interface→interface casts of an existing value.🤖 Generated with Claude Code