From eb7a99172ff6104f3e2e7852680137ec7ec65d96 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 8 Oct 2026 16:57:12 -0700 Subject: [PATCH] Test class downcasts inline and borrow the checked operand `x as Sub` lowered to a call to an interned %class.assert helper that unwrapped a union box, retained the payload twice, compared the class-id range, and threw on mismatch; every use paid a call, three retains, two releases and a pending check (2,152 sites in tsc-ts). Plain operands (locals and field reads, which isSafeToRepeat already admits) now lower to the check itself: per compatible union arm a tag test plus, where the arm is not already the target or a subclass, an instanceOf range test, selecting the downcast payload, with the helper's exact TypeError as the final arm. Other operands keep the helper so they are evaluated once. The backend borrows through the result: instanceOf reads its operand as a receiver instead of retaining a temporary, and downcast/upcast are borrowable, edge-preserving projections, so `(node as Sub).field` and method receivers on a cast no longer touch reference counts. The mismatch arm unwinds directly (previous commit) and stays out of the hot path. Corpus 4432 pins the Node-observable behavior of failing casts (a TypeError before any later read, unwinding through finally blocks and callbacks) and of successful casts from bases, subclasses and unions; a harness test pins the exact messages, which Node cannot oracle. --- .../src/backend/llvm/borrowed-receivers.ts | 2 + packages/compiler/src/backend/llvm/emitter.ts | 7 + .../src/backend/llvm/reference-effects.ts | 3 + .../src/frontend/lowering/class-assertions.ts | 53 +++++++ .../test/ts7/baselines/order-parity.json | 6 + .../corpus/4432-checked-downcast-failures.ts | 148 ++++++++++++++++++ tests/harness/errors.test.ts | 83 ++++++++++ 7 files changed, 302 insertions(+) create mode 100644 tests/corpus/4432-checked-downcast-failures.ts diff --git a/packages/compiler/src/backend/llvm/borrowed-receivers.ts b/packages/compiler/src/backend/llvm/borrowed-receivers.ts index 8e315a5e5..b85a3c66f 100644 --- a/packages/compiler/src/backend/llvm/borrowed-receivers.ts +++ b/packages/compiler/src/backend/llvm/borrowed-receivers.ts @@ -7,6 +7,8 @@ function stableProjection(host: LlvmEmitterContext, e: IrExpr): boolean { case "varRef": return host.canBorrowReceiver(e); case "unionNarrow": + case "downcast": + case "upcast": return stableProjection(host, e.value); case "fieldGet": case "recordGet": diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 02c90cdb7..2a1f6c727 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6422,6 +6422,10 @@ export class LlEmitter { const union = this.emitUnionProjection(e.value); return { name: this.unionPeek(union.name, e.unionId), type: e.type }; } + if ((e.kind === "downcast" || e.kind === "upcast") && e.value.type.kind === "object") { + // Prefix layouts: a class cast reads the operand's own pointer. + return { name: this.emitReadReceiver(e.value).name, type: e.type }; + } // A nullable-pointer field has no box to borrow. Projections use // emitUnionProjection; other borrowers get an owned heap box. if (this.nullableFieldGet(e)) return this.emitExpr(e); @@ -6648,6 +6652,9 @@ export class LlEmitter { ); } case "unionNarrow": + // Class casts reinterpret the same pointer (prefix layouts). + case "downcast": + case "upcast": return this.canBorrowReceiver(e.value); case "fieldGet": case "recordGet": diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index c5483009a..8d71a986c 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -54,6 +54,9 @@ function expressionPreservesEdges( case "unionNarrow": case "unionIsTag": case "unionWrap": + case "downcast": + case "upcast": + case "instanceOf": case "strConcat": case "strEq": case "strCmp": diff --git a/packages/compiler/src/frontend/lowering/class-assertions.ts b/packages/compiler/src/frontend/lowering/class-assertions.ts index c6c267615..900c32426 100644 --- a/packages/compiler/src/frontend/lowering/class-assertions.ts +++ b/packages/compiler/src/frontend/lowering/class-assertions.ts @@ -9,6 +9,7 @@ import { typeEquals, typeKey, } from "../../ir/ir.js"; +import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import type { Lowerer } from "./lowerer.js"; /** Whether any stored payload can supply a checked view of this class. */ @@ -66,6 +67,58 @@ export function checkedClassAssertion( if (typeEquals(source.type, target)) return source; if (lowerer.isSubclassOf(source.type.className, target.className)) return convert(source); } + // A plain read can be tested where it is used: the class-id range test + // runs inline and successful projections borrow the operand, while the + // mismatch arm throws the helper's exact TypeError. Other operands keep + // the helper so they are evaluated exactly once. + if (isSafeToRepeat(source)) { + if (!union) return convert(source); + let checked = failure(); + for (let tag = union.arms.length - 1; tag >= 0; tag--) { + const arm = union.arms[tag]!; + if (!compatible(arm)) continue; + const isTag: IrExpr = { + kind: "unionIsTag", + unionId: union.id, + tag, + negated: false, + value: source, + type: BOOL, + loc, + }; + const payload: IrExpr = { + kind: "unionNarrow", + unionId: union.id, + tag, + value: source, + type: arm, + loc, + }; + let cond: IrExpr = isTag; + let then: IrExpr = payload; + if (lowerer.isSubclassOf(arm.className, target.className) && !typeEquals(arm, target)) { + then = { kind: "upcast", value: payload, type: target, loc }; + } else if (!typeEquals(arm, target)) { + cond = { + kind: "logical", + op: "&&", + left: isTag, + right: { + kind: "instanceOf", + value: payload, + className: target.className, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + then = { kind: "downcast", value: payload, type: target, loc }; + } + checked = { kind: "ternary", cond, then, else_: checked, type: target, loc }; + } + return checked; + } const key = `classassert:${typeKey(source.type)}:${typeKey(target)}`; let name = lowerer.coercions.retags.get(key); if (!name) { diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 632bbb6ac..9581c823c 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15085,6 +15085,12 @@ ], "diags": [] }, + "/tests/corpus/4432-checked-downcast-failures.ts": { + "order": [ + "/tests/corpus/4432-checked-downcast-failures.ts" + ], + "diags": [] + }, "/tests/corpus/array-sort-runtime-comparator.ts": { "order": [ "/tests/corpus/array-sort-runtime-comparator.ts" diff --git a/tests/corpus/4432-checked-downcast-failures.ts b/tests/corpus/4432-checked-downcast-failures.ts new file mode 100644 index 000000000..61696d011 --- /dev/null +++ b/tests/corpus/4432-checked-downcast-failures.ts @@ -0,0 +1,148 @@ +// Class downcasts (`x as Sub`) test the stored class where they are used. +// Successful casts must read the same object; a cast past the stored class +// must fail as a TypeError before any later read, unwinding through finally +// blocks and callbacks exactly like Node's failing member read does. + +class Node { + parent: Node | undefined = undefined; + readonly kind: number; + constructor(kind: number) { + this.kind = kind; + } +} + +class Identifier extends Node { + readonly text: string; + constructor(text: string) { + super(1); + this.text = text; + } + describe(): string { + return `id:${this.text}`; + } +} + +class Literal extends Node { + readonly value: number; + constructor(value: number) { + super(2); + this.value = value; + } +} + +class Keyword extends Identifier { + constructor(text: string) { + super(text); + } + override describe(): string { + return `kw:${this.text}`; + } +} + +class Other { + readonly tag: string; + constructor(tag: string) { + this.tag = tag; + } +} + +function textOf(node: Node): string { + return (node as Identifier).text; +} + +function parentText(node: Node): number { + return (node.parent as Identifier).text.length; +} + +function valueOf(value: Node | Other | undefined): number { + return (value as Literal).value + 1; +} + +function describeOptional(node: Node | undefined): string { + return (node as Identifier).describe(); +} + +function firstChild(nodes: Node[]): Node { + return nodes[0]!; +} + +function lengthOfFirst(nodes: Node[]): number { + return (firstChild(nodes) as Identifier).text.length; +} + +function report(label: string, run: () => unknown): void { + try { + console.log(label, "ok", run()); + } catch (e) { + console.log(label, e instanceof TypeError, (e as Error).name); + } +} + +const id = new Identifier("alpha"); +const kw = new Keyword("return"); +const lit = new Literal(41); +lit.parent = id; +id.parent = lit; +kw.parent = kw; + +// Successful casts, including through subclasses and unions. +report("text", () => textOf(id)); +report("text-sub", () => textOf(kw)); +report("parent", () => parentText(lit)); +report("value", () => valueOf(lit)); +report("describe", () => describeOptional(kw)); +report("first", () => lengthOfFirst([kw, lit])); + +// Casts past the stored class. Node fails at the following member read. +report("text-bad", () => textOf(lit).length); +report("parent-bad", () => parentText(id)); +report("value-undefined", () => valueOf(undefined).toFixed(1)); +report("describe-undefined", () => describeOptional(undefined)); +report("describe-bad", () => describeOptional(lit)); +report("first-bad", () => lengthOfFirst([lit, id])); + +// The failure unwinds through finally blocks in order. +const trail: string[] = []; +function nested(node: Node, depth: number): number { + try { + if (depth === 0) return (node as Identifier).text.length; + return nested(node, depth - 1) + 1; + } finally { + trail.push(`f${depth}`); + } +} +report("nested-ok", () => nested(id, 3)); +report("nested-bad", () => nested(lit, 3)); +console.log(trail.join(",")); + +// Inside callbacks: the first failing element stops the iteration. +const seen: number[] = []; +report("map", () => + [id, kw, lit, id].map((node) => { + seen.push(node.kind); + return (node as Identifier).text.length; + }), +); +console.log(seen.join(",")); + +// Hot loop over mixed values: casts guarded by the kind check never fail. +let total = 0; +const mixed: Node[] = []; +for (let i = 0; i < 2000; i++) mixed.push(i % 3 === 0 ? new Literal(i) : new Identifier(`n${i}`)); +for (const node of mixed) { + if (node.kind === 2) total += (node as Literal).value; + else total += (node as Identifier).text.length; +} +console.log(total); + +// Optional storage read through a cast and a non-null assertion. +const holder: { node: Node | undefined } = { node: kw }; +report("holder", () => (holder.node as Identifier).describe()); +report("holder-assert", () => holder.node!.kind); +holder.node = undefined; +report("holder-bad", () => (holder.node as Identifier).describe()); +report("holder-assert-bad", () => holder.node!.kind); + +const others: (Node | Other)[] = [new Other("x"), id]; +report("other", () => (others[1] as Identifier).text); +report("other-bad", () => (others[0] as Identifier).text.length); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index f82f7f4fa..1b6d69044 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -186,6 +186,89 @@ console.log("sync done"); }); }); +// `x as Sub` on a class value is a CHECKED downcast, and `x!` / `u as Arm` +// on a union value is a checked extraction: a stored value outside the +// target throws a catchable TypeError where Node's erasure would let the +// value flow on. The type and message are scriptc's contract (corpus 4432 +// pins the Node-observable part: the failure is a TypeError raised before +// any later read). Plain operands are tested inline at the use and call +// operands through the interned helper; both must fail identically. +describe("checked class downcasts and extractions", () => { + test("failed downcasts throw the TypeError naming the target class", async () => { + const r = await compileAndRun( + "checked-downcast-messages", + `class Base { + kind = 0; +} +class Sub extends Base { + text = "sub"; +} +class Other { + tag = "other"; +} +function direct(b: Base): string { + return (b as Sub).text; +} +function optional(b: Base | undefined): string { + return (b as Sub).text; +} +function mixed(v: Base | Other): string { + return (v as Sub).text; +} +function make(b: Base | undefined): Base | undefined { + return b; +} +function called(b: Base | undefined): string { + return (make(b) as Sub).text; +} +function asserted(b: Base | undefined): number { + return b!.kind; +} +function assertedCall(b: Base | undefined): number { + return make(b)!.kind; +} +const runs: Array<() => unknown> = [ + () => direct(new Sub()), + () => direct(new Base()), + () => optional(undefined), + () => optional(new Base()), + () => mixed(new Other()), + () => called(new Base()), + () => called(undefined), + () => asserted(new Base()), + () => asserted(undefined), + () => assertedCall(undefined), +]; +for (const run of runs) { + try { + console.log("ok", run()); + } catch (e) { + console.log(e instanceof TypeError, String(e)); + } +} +direct(new Base()); +`, + ); + expect(r.stdout).toBe( + [ + "ok sub", + "true TypeError: Value is not an instance of 'Sub'", + "true TypeError: Value is not an instance of 'Sub'", + "true TypeError: Value is not an instance of 'Sub'", + "true TypeError: Value is not an instance of 'Sub'", + "true TypeError: Value is not an instance of 'Sub'", + "true TypeError: Value is not an instance of 'Sub'", + "ok 0", + "true TypeError: undefined is not representable in the target union (a value narrowed or asserted past it still held it)", + "true TypeError: undefined is not representable in the target union (a value narrowed or asserted past it still held it)", + "", + ].join("\n"), + ); + expect(r.stderr).toBe("Uncaught TypeError: Value is not an instance of 'Sub'\n"); + expect(r.exitCode).toBe(1); + }); +}); + // `e as C` on a catch binding is a CHECKED cast: a payload outside C's // hierarchy throws a catchable TypeError where Node's erasure would read // undefined off the value — dynCheck's documented trust-but-verify stance