From d79772218795aece13ead646f9314ea6278771eb Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:58:05 -0500 Subject: [PATCH] feat(codemode): carry cause and own data across the error boundary (#49390) --- packages/codemode/interpreter-support.md | 7 ++- packages/codemode/src/interpreter/errors.ts | 16 +++-- .../codemode/src/interpreter/extensions.ts | 56 ++++++++++++++++-- packages/codemode/test/extensions.test.ts | 59 ++++++++++++++++++- packages/codemode/test/parity.test.ts | 10 ++++ 5 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index b59d69519c4..260b892cb1b 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -182,7 +182,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and - non-callable values are not constructors. + non-callable values are not constructors. Error constructors take the ES2022 options object, so + `new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. - [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`. @@ -472,6 +473,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls - [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or plain data). +- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's + `code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its + own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is + left behind rather than replacing the error. - [ ] Program functions as arguments to extension code (callbacks such as `forEach`). - [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next step if `new X()` in a program is ever needed. diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index 98911816af8..0f2afe11862 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type import { containsRuntimeReference } from "./references.js" import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js" import { constructor, methods, prototypeFrom, receiver } from "./native.js" -import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js" +import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js" import type { Interpreter } from "./interpreter.js" import { formatValue } from "../stdlib/console.js" import { coerceToString } from "../stdlib/value.js" @@ -146,9 +146,17 @@ export const errorGlobal = (type: ErrorType, ctx: Interpreter) => { const prototype = builtins[type] const construct = (args: Array, newTarget: Callable) => { const proto = prototypeFrom(newTarget, prototype) - return type === "AggregateError" - ? constructAggregateErrorValue(ctx, args, proto) - : Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0]))) + const created = + type === "AggregateError" + ? constructAggregateErrorValue(ctx, args, proto) + : Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0]))) + // ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all. + const options = args[type === "AggregateError" ? 2 : 1] + if (!(options instanceof Obj) || !has(options, "cause")) return created + return Effect.map(created, (value) => { + define(value, "cause", get(options, "cause"), hidden) + return value + }) } const ctor: Native = constructor(builtins, prototype, { name: type, diff --git a/packages/codemode/src/interpreter/extensions.ts b/packages/codemode/src/interpreter/extensions.ts index ca57d013fbe..543465742ce 100644 --- a/packages/codemode/src/interpreter/extensions.ts +++ b/packages/codemode/src/interpreter/extensions.ts @@ -5,13 +5,16 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js" import type { Interpreter } from "./interpreter.js" import { createErrorValue, isErrorType } from "./intrinsics.js" import { MAX_VALUE_DEPTH } from "./limits.js" -import { Throw, typeError } from "./model.js" +import { PendingThrow, Throw, typeError } from "./model.js" import { fn } from "./native.js" import { Callable, define, entries, get, + has, + hidden, + keys, Arr, Bytes, DateObj, @@ -60,14 +63,28 @@ export const extensionGlobals = ( ) { throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`) } + if (seen.has(value)) throw typeError(`${label} contains a circular value.`) + seen.add(value) if (value instanceof ErrorObj) { const name = coerceToString(get(value, "name")) const message = get(value, "message") const text = message === undefined ? "" : coerceToString(message) - return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text) + const copied = + name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text) + for (const key of new Set(["cause", ...keys(value)])) { + if (uncrossed.has(key) || !has(value, key)) continue + const item = crossing(() => next(get(value, key))) + if (item === left) continue + Object.defineProperty(copied, key, { + value: item, + writable: true, + configurable: true, + enumerable: key !== "cause", + }) + } + seen.delete(value) + return copied } - if (seen.has(value)) throw typeError(`${label} contains a circular value.`) - seen.add(value) const copied = value instanceof Arr ? value.items.map(next) @@ -85,18 +102,29 @@ export const extensionGlobals = ( if (isPrimitive(value)) return value if (typeof value === "function") return wrap(value, label) if (value !== null && typeof value === "object") { + const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen) if (value instanceof Date) return new DateObj(builtins.Date, value.getTime()) if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags) if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value)) if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0))) if (value instanceof Error) { - return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message) + if (seen.has(value)) throw typeError(`${label} produced a circular value.`) + seen.add(value) + const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message) + const fields = value as unknown as Record + for (const key of new Set(["cause", ...Object.keys(value)])) { + if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue + const item = crossing(() => next(fields[key], `${label}.${key}`)) + if (item === left) continue + define(copied, key, item, key === "cause" ? hidden : undefined) + } + seen.delete(value) + return copied } if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href)) if (value instanceof URLSearchParams) { return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value)) } - const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen) if (value instanceof Map) { const wrapped = new MapObj(builtins.Map) for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label)) @@ -160,6 +188,22 @@ export const extensionGlobals = ( ) } +/** + * An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`, + * `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot + * cross (a socket, a handle, a function) is left behind so the error itself always arrives. + */ +const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"]) +const left = Symbol("left behind") +const crossing = (convert: () => unknown): unknown => { + try { + return convert() + } catch (reason) { + if (reason instanceof PendingThrow) return left + throw reason + } +} + const hostErrors = new Map([ ["TypeError", TypeError], ["RangeError", RangeError], diff --git a/packages/codemode/test/extensions.test.ts b/packages/codemode/test/extensions.test.ts index df208af10cd..3c576b484fa 100644 --- a/packages/codemode/test/extensions.test.ts +++ b/packages/codemode/test/extensions.test.ts @@ -163,12 +163,26 @@ describe("values are converted at the boundary, never shared", () => { expect(held[0]).toEqual({ a: 1 }) }) - test("a program Error crosses as a host Error with its name and message", async () => { + test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => { held.length = 0 await value(`keep(new TypeError("bad"))`) expect(held[0]).toBeInstanceOf(TypeError) expect((held[0] as Error).message).toBe("bad") expect(Object.keys(held[0] as object)).toEqual([]) + held.length = 0 + await value(` + const e = new Error("m", { cause: new RangeError("root") }) + e.code = "ENOENT"; e.detail = { path: "x" } + e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1 + keep(e)`) + const crossed = held[0] as Error & Record + expect(crossed.cause).toBeInstanceOf(RangeError) + expect((crossed.cause as Error).message).toBe("root") + expect(Object.keys(crossed)).toEqual(["code", "detail"]) + expect(crossed.detail).toEqual({ path: "x" }) + expect(crossed.stack).not.toBe("chosen") + expect(String(crossed)).toBe("Error: m") + expect(crossed.constructor).toBe(Error) }) test("an Error with an unknown name crosses as a plain Error", async () => { @@ -231,6 +245,49 @@ describe("host errors", () => { ]) }) + test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => { + class Handle {} + const target = CodeMode.make({ + extensions: [ + Extension.make({ + name: "fs", + globals: { + open: () => { + const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), { + code: "ENOENT", + errno: -2, + path: "x", + detail: { retried: true }, + handle: new Handle(), + retry: () => 1, + }) + throw new Error("open failed", { cause: error }) + }, + }, + }), + ], + }) + expect( + await value( + `try { open() } catch (e) { + const c = e.cause + return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c] + }`, + target, + ), + ).toEqual([ + "open failed", + [], + true, + "ENOENT", + -2, + "x", + { retried: true }, + ["code", "errno", "path", "detail"], + false, + ]) + }) + test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => { const reason = { status: 404, nested: { a: 1 } } const target = CodeMode.make({ diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index f7a076caee7..8d9c8ba5cf3 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -350,6 +350,16 @@ describe("Error values and instanceof", () => { expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false) }) + test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => { + expect( + await value(` + const inner = new Error("root") + const e = new TypeError("m", { cause: inner }) + const agg = new AggregateError([], "a", { cause: 3 }) + return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`), + ).toEqual([true, [], false, true, 3]) + }) + test("thrown errors keep instanceof through try/catch", async () => { expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([ true,