diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 6589d7c04d..afac089f8e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,4 +1,5 @@ import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js"; +import { adaptPromiseExecutor } from "./promise-executor-adapter.js"; import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; import { constructedFromValue, @@ -9856,7 +9857,9 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { // catch-side instanceof and the uncaught printer working). First // settle wins, exactly JS: reject-after-resolve and double-reject // are no-ops, and an executor throw after any settle is swallowed. - const executor = lowerer.lowerExpr(args[0]!); + let executor = lowerer.lowerExpr(args[0]!); + if (executor.type.kind !== "func") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!)); + executor = adaptPromiseExecutor(lowerer, executor, type.inner, loc) ?? executor; if (executor.type.kind !== "func") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!)); if (executor.type.params.length > 1) { const rj = executor.type.params[1]!; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 6cbe5d7a4a..49f49987d8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2132,8 +2132,13 @@ export function caughtRead( } // Unnarrowed reads cross into unknown. The caught-value dispatch pass // retains known native class payloads through live identity capsules; - // other payload kinds use the runtime's ordinary conversion. - if (narrowed?.kind === "dyn") { + // other payload kinds use the runtime's ordinary conversion. A binding + // annotated `any` (`catch (error: any)`) reads the same checked-dynamic + // value: its member reads, writes and deletes go through it. + if ( + narrowed?.kind === "dyn" || + (narrowed === null && (lowerer.typeOf(node).flags & ts.TypeFlags.Any) !== 0) + ) { return { kind: "caughtToDyn", value: ref, type: DYN, loc }; } lowerer.unsupported("SC1063", node); diff --git a/packages/compiler/src/frontend/lowering/promise-executor-adapter.ts b/packages/compiler/src/frontend/lowering/promise-executor-adapter.ts new file mode 100644 index 0000000000..09d228b05e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/promise-executor-adapter.ts @@ -0,0 +1,102 @@ +import { varRef } from "../../ir/build.js"; +import { + type IrExpr, + type IrLocal, + type IrParam, + type IrType, + type SrcLoc, + VOID, + funcOf, + typeKey, +} from "../../ir/ir.js"; +import { newFnCtx, type Lowerer } from "./lowerer.js"; + +/** An executor VALUE whose parameters are checked-dynamic (a JavaScript + * function declaration: `function run(resolve, reject) {...}; return new + * Promise(run)`): the promise's native executor is an adapter closure that + * receives the native (resolve, reject) pair and calls the original with + * both boxed into the checked-dynamic tree, so the JavaScript body calls + * them like any function value. Null when the executor's shape is not + * adaptable (more than two parameters, a rest parameter, or a parameter + * that neither is dyn nor already the native closure). */ +export function adaptPromiseExecutor( + lowerer: Lowerer, + executor: IrExpr, + inner: IrType, + loc: SrcLoc, +): IrExpr | null { + const execT = executor.type; + if (execT.kind !== "func" || execT.rest || execT.params.length > 2) return null; + if (!execT.params.some((p) => p.kind === "dyn")) return null; + const resolveT = funcOf(inner.kind === "void" ? [] : [inner], VOID); + const rejectT = funcOf([{ kind: "object", className: "%Error" }], VOID); + const native = [resolveT, rejectT]; + if ( + !execT.params.every((p, i) => p.kind === "dyn" || typeKey(p) === typeKey(native[i]!)) || + !native.slice(0, execT.params.length).every((t) => lowerer.dynConvertible(t)) + ) + return null; + const adaptT = funcOf(native, VOID); + const key = `promise.executor:${typeKey(execT)}:${typeKey(inner)}`; + let name = lowerer.arrHofHelpers.get(key); + if (name === undefined) { + name = `%promise.executor.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, name); + const impl = `${name}.impl`; + const captured: IrLocal = { id: "f.0", name: "f", type: execT, mutable: false, boxed: true }; + const params: IrParam[] = [ + { localId: "resolve.0", name: "resolve", type: resolveT }, + { localId: "reject.0", name: "reject", type: rejectT }, + ]; + const ctx = newFnCtx(false, null, null, VOID); + lowerer.fnStack.push(ctx); + let args: IrExpr[]; + try { + args = execT.params.map((p, i) => + lowerer.coerceToExpected(varRef(params[i]!.localId, native[i]!, loc), p), + ); + } finally { + lowerer.fnStack.pop(); + } + lowerer.liftedFns.push({ + name: impl, + params, + returnType: VOID, + captures: [{ localId: captured.id, name: captured.name, type: execT }], + locals: [ + captured, + ...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + ...ctx.locals, + ], + body: [ + { + kind: "exprStmt", + expr: { + kind: "callValue", + callee: varRef(captured.id, execT, loc), + args, + type: execT.ret, + loc, + }, + loc, + }, + ], + loc, + }); + lowerer.liftedFns.push({ + name, + params: [{ localId: captured.id, name: captured.name, type: execT }], + returnType: adaptT, + locals: [captured], + body: [ + { + kind: "return", + value: { kind: "closure", fnName: impl, captures: [captured.id], type: adaptT, loc }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [executor], type: adaptT, loc }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-js-named-promise-executor/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-js-named-promise-executor/main.ts.json new file mode 100644 index 0000000000..dd95018f06 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-js-named-promise-executor/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6490-js-named-promise-executor/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-catch-any-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-catch-any-bindings.ts.json new file mode 100644 index 0000000000..bc69a66e30 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-catch-any-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6491-catch-any-bindings.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6490-js-named-promise-executor/main.ts b/tests/corpus/6490-js-named-promise-executor/main.ts new file mode 100644 index 0000000000..79e2a17d78 --- /dev/null +++ b/tests/corpus/6490-js-named-promise-executor/main.ts @@ -0,0 +1,22 @@ +// A JavaScript library that builds its promise from a named executor +// function (`function run(resolve, reject) {...}; return new Promise(run)`, +// the classic retry helper shape): the executor's parameters are untyped, +// so it receives the native resolve and reject as checked-dynamic function +// values and calls them like any JavaScript function. +import { attempt } from "later"; +async function main(): Promise { + const v = await attempt(async (_bail: (e: Error) => void, n: number): Promise => { + if (n < 3) throw new Error("try " + n); + return "done after " + n; + }, 5); + console.log(v); + try { + await attempt((bail: (e: Error) => void): string => { + bail(new Error("stop")); + return "ignored"; + }, 5); + } catch (e) { + console.log("caught", (e as Error).message); + } +} +main(); diff --git a/tests/corpus/6490-js-named-promise-executor/node_modules/later/index.d.ts b/tests/corpus/6490-js-named-promise-executor/node_modules/later/index.d.ts new file mode 100644 index 0000000000..55956f01d9 --- /dev/null +++ b/tests/corpus/6490-js-named-promise-executor/node_modules/later/index.d.ts @@ -0,0 +1 @@ +export declare function attempt(fn: (bail: (e: Error) => void, n: number) => A | Promise, retries: number): Promise; diff --git a/tests/corpus/6490-js-named-promise-executor/node_modules/later/index.js b/tests/corpus/6490-js-named-promise-executor/node_modules/later/index.js new file mode 100644 index 0000000000..deefa6aee8 --- /dev/null +++ b/tests/corpus/6490-js-named-promise-executor/node_modules/later/index.js @@ -0,0 +1,31 @@ +"use strict"; +function attempt(fn, retries) { + function run(resolve, reject) { + let n = 0; + function bail(err) { + reject(err || new Error("Aborted")); + } + function onError(err) { + if (err.bail) { + bail(err); + return; + } + if (n > retries) reject(err); + else runAttempt(); + } + function runAttempt() { + n++; + let val; + try { + val = fn(bail, n); + } catch (err) { + onError(err); + return; + } + Promise.resolve(val).then(resolve).catch(onError); + } + runAttempt(); + } + return new Promise(run); +} +module.exports = { attempt }; diff --git a/tests/corpus/6490-js-named-promise-executor/node_modules/later/package.json b/tests/corpus/6490-js-named-promise-executor/node_modules/later/package.json new file mode 100644 index 0000000000..ce1f29bac3 --- /dev/null +++ b/tests/corpus/6490-js-named-promise-executor/node_modules/later/package.json @@ -0,0 +1,6 @@ +{ + "name": "later", + "version": "1.0.0", + "main": "./index.js", + "types": "./index.d.ts" +} diff --git a/tests/corpus/6490-js-named-promise-executor/tsconfig.json b/tests/corpus/6490-js-named-promise-executor/tsconfig.json new file mode 100644 index 0000000000..27d492dd27 --- /dev/null +++ b/tests/corpus/6490-js-named-promise-executor/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": { "strict": true, "types": ["node"] } +} diff --git a/tests/corpus/6491-catch-any-bindings.ts b/tests/corpus/6491-catch-any-bindings.ts new file mode 100644 index 0000000000..9979637b46 --- /dev/null +++ b/tests/corpus/6491-catch-any-bindings.ts @@ -0,0 +1,34 @@ +// A catch binding annotated `any` reads the caught value through its +// checked-dynamic face: member reads, expando writes, `delete` and `in` +// act on the thrown object itself, and a rethrow keeps its identity. +const thrown: Error[] = []; + +function fail(n: number): never { + const e = new Error(`failure ${n}`); + thrown.push(e); + throw e; +} + +for (const n of [1, 2]) { + try { + try { + fail(n); + } catch (error: any) { + error.bail = n === 1; + console.log("marked:", error.bail, "bail" in error, error.message); + if (error.bail) { + delete error.bail; + console.log("deleted:", error.bail, "bail" in error); + } + throw error; + } + } catch (outer) { + console.log("outer:", outer === thrown[n - 1], (outer as Error).message, "bail" in (outer as object)); + } +} + +try { + JSON.parse("{"); +} catch (error: any) { + console.log(error.name, typeof error.message, error instanceof SyntaxError); +}