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