From 5a56cc2564e99686c71a5cdd10e0da2d8fcdaf16 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 09:40:20 -0500 Subject: [PATCH] Answer catch-binding instanceof for values thrown as unknown A union, unknown or any value is converted to a dynamic value before it is thrown, and promise rejections and other runtime throws carry dynamic values too. A catch binding then holds a dynamic payload, which the native class test does not recognize: `e instanceof C` was false for every class of a hierarchy, and a read narrowed by it trapped. A test through a parameter of type unknown was already correct. When a class or one of its subclasses crosses into unknown, the class dynamic-dispatch pass now routes the catch-binding tests of that class (`e instanceof C`, the reads it narrows and `e as C`) through helpers that keep the native test and answer a dynamic payload with the dynamic class test, as standalone classes already do. The new "dynamic" caught test selects that payload, so native payloads are not converted. Programs whose classes never cross into unknown compile as before. --- .../compiler/src/backend/llvm/expr-dynamic.ts | 23 +++ .../lowering/caught-value-dispatch.ts | 176 +++++++++++++++++- .../lowering/class-dynamic-dispatch.ts | 9 + packages/compiler/src/ir/ir.ts | 6 +- packages/compiler/src/ir/validate.ts | 2 +- ...caught-instanceof-dynamic-payloads.ts.json | 6 + ...6819-caught-instanceof-dynamic-payloads.ts | 80 ++++++++ ...caught-instanceof-dynamic-payloads.ts.json | 1 + 8 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json create mode 100644 tests/corpus/6819-caught-instanceof-dynamic-payloads.ts create mode 100644 tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 83cbe6d54..5b5ec33bc 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -1038,6 +1038,29 @@ export function emitDynamicExpr( B.line(`${n} = xor i1 ${t}, true`); return { name: n, type: e.type }; } + if (e.test === "dynamic") { + // A REF payload whose retain adapter is the dyn adapter (sc_cd's + // by-reference arm). + host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); + const kp = B.tmp(), + k = B.tmp(), + isRef = B.tmp(), + rp = B.tmp(), + r = B.tmp(), + isDyn = B.tmp(), + t = B.tmp(); + B.line(`${kp} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 1`); + B.line(`${k} = load i32, ptr ${kp}`); + B.line(`${isRef} = icmp eq i32 ${k}, 4`); + B.line(`${rp} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 5`); + B.line(`${r} = load ptr, ptr ${rp}`); + B.line(`${isDyn} = icmp eq ptr ${r}, @scr_dyn_retain_v`); + B.line(`${t} = and i1 ${isRef}, ${isDyn}`); + if (e.negated !== true) return { name: t, type: e.type }; + const n = B.tmp(); + B.line(`${n} = xor i1 ${t}, true`); + return { name: n, type: e.type }; + } if (e.test === "object") { host.declare(`declare zeroext i1 @scr_caught_is_object(ptr)`); const t = B.tmp(); diff --git a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts index a07741461..645ff8d2c 100644 --- a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts @@ -1,6 +1,16 @@ import { varRef } from "../../ir/build.js"; -import { BOOL, CAUGHT, DYN, type IrFunction, type IrType } from "../../ir/ir.js"; +import { + BOOL, + CAUGHT, + DYN, + type IrExpr, + type IrFunction, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import { classInstanceOf } from "./class-dynamic-dispatch.js"; +import type { ClassInfo } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; /** Exception snapshots retain native objects. Recover their known layouts @@ -10,6 +20,10 @@ export class CaughtValueDispatch { private readonly thrownClasses = new Set(); private readonly convertedClasses = new Set(); private helper: IrFunction | null = null; + /** The dynamic-payload helpers of class tests and extractions, by kind + * and class (dynamicPayloads). */ + private readonly payloadHelpers = new Map(); + private readonly payloadHelperFns = new Set(); process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { const consumers: IrFunction[] = []; @@ -81,4 +95,164 @@ export class CaughtValueDispatch { } return changed; } + + /** A thrown unknown or union value, and the runtime's own dynamic throws, + * reach a catch binding as a dynamic payload. When an instance of class C + * can be such a payload (C or a subclass of C crosses into unknown, the + * `boxed` classes), `e instanceof C`, the reads it narrows and `e as C` in + * `sites` also answer through the dynamic class test, as standalone classes + * already do. Programs that never box C keep the native payload test. */ + dynamicPayloads( + lowerer: Lowerer, + sites: ReadonlySet, + boxed: ReadonlySet, + ): boolean { + if (boxed.size === 0 || sites.size === 0) return false; + const reached = new Map(); + const reachable = (className: string | undefined): ClassInfo | null => { + if (className === undefined) return null; + let info = reached.get(className); + if (info === undefined) { + const found = lowerer.classes.get(className); + info = + found && + lowerer.inHierarchy(found) && + [...boxed].some((name) => name === className || lowerer.isSubclassOf(name, className)) + ? found + : null; + reached.set(className, info); + } + return info; + }; + let changed = false; + const qualifies = (expr: IrExpr): boolean => + expr.kind === "caughtTest" + ? expr.test === "instanceof" && reachable(expr.className) !== null + : (expr.kind === "caughtNarrow" || expr.kind === "caughtCheck") && + expr.type.kind === "object" && + reachable(expr.type.className) !== null; + for (const fn of sites) { + // The conversions themselves test the native payload. + if (fn === this.helper || this.payloadHelperFns.has(fn)) continue; + const found = !everyStmtList(fn.body, { stmt: () => true, expr: (expr) => !qualifies(expr) }); + if (!found) continue; + fn.body = transformStmtList(fn.body, { + stmt: (stmt) => stmt, + expr: (expr) => { + if (expr.kind === "caughtTest" && expr.test === "instanceof") { + const info = reachable(expr.className); + if (!info) return expr; + changed = true; + const call: IrExpr = { + kind: "call", + callee: this.payloadHelper(lowerer, "instanceof", info, expr.loc).name, + args: [expr.value], + type: BOOL, + loc: expr.loc, + }; + return expr.negated + ? { kind: "unary", op: "!", operand: call, type: BOOL, loc: expr.loc } + : call; + } + if ( + (expr.kind === "caughtNarrow" || expr.kind === "caughtCheck") && + expr.type.kind === "object" + ) { + const info = reachable(expr.type.className); + if (!info) return expr; + changed = true; + const kind = expr.kind === "caughtNarrow" ? "narrow" : "check"; + return { + kind: "call", + callee: this.payloadHelper(lowerer, kind, info, expr.loc).name, + args: [expr.value], + type: expr.type, + loc: expr.loc, + }; + } + return expr; + }, + }); + } + return changed; + } + + /** `%caught.instanceof:C`, `%caught.narrow:C` and `%caught.check:C`: the + * native payload test or extraction, and otherwise a value thrown as + * unknown tested against C and checked into C. A check of a payload that + * is not a C keeps caughtCheck's TypeError. */ + private payloadHelper( + lowerer: Lowerer, + kind: "instanceof" | "narrow" | "check", + info: ClassInfo, + loc: SrcLoc, + ): IrFunction { + const className = info.def.name; + const name = `%caught.${kind}:${className}`; + const existing = this.payloadHelpers.get(name); + if (existing) return existing; + const caught = (): IrExpr => varRef("caught", CAUGHT, loc); + const dynamic = (): IrExpr => ({ kind: "caughtToDyn", value: caught(), type: DYN, loc }); + const native: IrExpr = { + kind: "caughtTest", + value: caught(), + test: "instanceof", + className, + type: BOOL, + loc, + }; + // Only a value thrown as unknown can hold an instance besides a native + // payload, which the native test answers exactly. + const dynamicInstance = (): IrExpr => ({ + kind: "logical", + op: "&&", + left: { kind: "caughtTest", value: caught(), test: "dynamic", type: BOOL, loc }, + right: classInstanceOf(lowerer, dynamic(), info, loc), + type: BOOL, + loc, + }); + const type: IrType = { kind: "object", className }; + const returnType = kind === "instanceof" ? BOOL : type; + let value: IrExpr; + if (kind === "instanceof") { + value = { + kind: "ternary", + cond: native, + then: { kind: "boolLit", value: true, type: BOOL, loc }, + else_: dynamicInstance(), + type: BOOL, + loc, + }; + } else if (kind === "narrow") { + value = { + kind: "ternary", + cond: native, + then: { kind: "caughtNarrow", value: caught(), type, loc }, + else_: lowerer.coerceToExpected(dynamic(), type), + type, + loc, + }; + } else { + value = { + kind: "ternary", + cond: dynamicInstance(), + then: lowerer.coerceToExpected(dynamic(), type), + else_: { kind: "caughtCheck", value: caught(), className, type, loc }, + type, + loc, + }; + } + const helper: IrFunction = { + name, + params: [{ localId: "caught", name: "caught", type: CAUGHT }], + locals: [{ id: "caught", name: "caught", type: CAUGHT, mutable: false }], + returnType, + body: [{ kind: "return", value, loc }], + loc, + }; + this.payloadHelpers.set(name, helper); + this.payloadHelperFns.add(helper); + lowerer.liftedFns.push(helper); + return helper; + } } diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index cb5c28444..252e3fa4b 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -762,6 +762,7 @@ export class ClassDynamicDispatch { const rewrite = new Set(); const toPrimitiveSites = new Set(); const hasKeySites = new Set(); + const caughtSites = new Set(); const discover = (type: IrType): void => { const key = typeKey(type); if (seenTypes.has(key)) return; @@ -827,6 +828,13 @@ export class ClassDynamicDispatch { case "dynHasKey": hasKeySites.add(fn); break; + case "caughtTest": + if (expr.test === "instanceof") caughtSites.add(fn); + break; + case "caughtNarrow": + case "caughtCheck": + if (expr.type.kind === "object") caughtSites.add(fn); + break; case "dynKeyGet": case "dynInvoke": rewrite.add(fn); @@ -865,6 +873,7 @@ export class ClassDynamicDispatch { return true; }, }); + changed = this.caughtDispatch.dynamicPayloads(lowerer, caughtSites, this.boxed) || changed; if (this.boxed.size === 0 && rewrite.size === 0) return changed; const converting = this.convertingHints(lowerer); const calling = this.calledConversions(lowerer); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 359d45ee7..38ebe4561 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3544,11 +3544,13 @@ export type IrExpr = * excluding callable and primitive references; * "instanceof" requires `className`: hierarchy payloads use the vtable * interval, while standalone classes use their exact retain adapter. - * Other payloads answer false. `negated` flips the result (the `!==` spelling). */ + * Other payloads answer false. "dynamic" is a value thrown as unknown + * (the payload caughtToDyn returns by reference). `negated` flips the + * result (the `!==` spelling). */ | { kind: "caughtTest"; value: IrExpr; - test: "string" | "number" | "boolean" | "object" | "instanceof"; + test: "string" | "number" | "boolean" | "object" | "instanceof" | "dynamic"; className?: string; negated?: boolean; type: IrType; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 9635ceb91..74f526e06 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -1117,7 +1117,7 @@ function validateFunction( // helpers. They are not source-level types and never return as values. if (fn.returnType.kind === "caught") err("return type is caught", fn.loc); for (const p of fn.params) { - if (p.type.kind === "caught" && fn.name !== "%caught.dynamicValue") + if (p.type.kind === "caught" && !fn.name.startsWith("%caught.")) err(`param "${p.name}" is caught-typed`, fn.loc); if (!locals.has(p.localId)) { err(`param "${p.name}" has no local entry "${p.localId}"`, fn.loc); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json new file mode 100644 index 000000000..6e7228d5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts b/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts new file mode 100644 index 000000000..c42a803f5 --- /dev/null +++ b/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts @@ -0,0 +1,80 @@ +// `e instanceof C` on a catch binding when the thrown value crossed into +// unknown first: a union, an unknown or any value, a rethrown binding and an +// awaited rejection. The narrowed reads and `e as C` read the same instance. +abstract class Shape { + sides = 0; +} +class Polygon extends Shape { + name = "polygon"; +} +class Square extends Polygon { + size = 3; +} +class Plain { + w = 4; +} +class CodedError extends Error { + code = 7; +} + +function report(label: string, e: unknown): void { + console.log(label, "via unknown", e instanceof Shape, e instanceof Polygon, e instanceof Square, e instanceof Plain); +} + +function attempt(label: string, body: () => void): void { + try { + body(); + } catch (e) { + report(label, e); + console.log(label, "binding", e instanceof Shape, e instanceof Polygon, e instanceof Square, e instanceof Plain, e instanceof Error, e instanceof CodedError); + console.log(label, "negated", !(e instanceof Shape), !(e instanceof Error)); + if (e instanceof Polygon) console.log(label, "narrowed", e.name, e.sides); + if (e instanceof Square) console.log(label, "square", e.size); + if (e instanceof CodedError) console.log(label, "coded", e.code, e.message); + if (e instanceof Shape && !(e instanceof Square)) console.log(label, "shape only", (e as Polygon).name); + } +} + +function throwUnknown(value: unknown): never { + throw value; +} +const choose = (n: number): Polygon | Plain | string => (n === 0 ? new Square() : n === 1 ? new Plain() : n === 2 ? new Polygon() : "text"); +const maybe = (n: number): Square | undefined => (n === 0 ? new Square() : undefined); +const failure = (n: number): CodedError | string => (n === 0 ? new CodedError("failed") : "failed"); + +for (let n = 0; n < 4; n++) attempt(`union ${n}`, () => { throw choose(n); }); +attempt("optional", () => { throw maybe(0); }); +attempt("optional undefined", () => { throw maybe(1); }); +attempt("error union", () => { throw failure(0); }); +attempt("error union string", () => { throw failure(1); }); +attempt("unknown", () => throwUnknown(new Square())); +attempt("unknown plain", () => throwUnknown(new Plain())); +attempt("unknown error", () => throwUnknown(new CodedError("unknown"))); +const anyValue: any = new Polygon(); +attempt("any", () => { throw anyValue; }); +attempt("typed", () => { const shape: Shape = new Square(); throw shape; }); +attempt("rethrow", () => { + try { + throw choose(0); + } catch (inner) { + throw inner; + } +}); + +async function main(): Promise { + try { + await Promise.reject(choose(0)); + } catch (e) { + console.log("rejected", e instanceof Shape, e instanceof Polygon, e instanceof Square, e instanceof Plain); + if (e instanceof Square) console.log("rejected square", e.size, e.name); + } + try { + await (async () => { + throw failure(0); + })(); + } catch (e) { + console.log("async error", e instanceof Error, e instanceof CodedError); + if (e instanceof CodedError) console.log("async coded", e.code); + } +} +void main(); diff --git a/tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json b/tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json new file mode 100644 index 000000000..2809fc670 --- /dev/null +++ b/tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json @@ -0,0 +1 @@ +{ "plain": 3200, "san": 30100 }