mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-29 17:09:40 +08:00
feat(codemode): carry cause and own data across the error boundary (#49390)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const prototype = builtins[type]
|
||||
const construct = (args: Array<unknown>, 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<R> = constructor<R>(builtins, prototype, {
|
||||
name: type,
|
||||
|
||||
@@ -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 = <R>(
|
||||
) {
|
||||
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 = <R>(
|
||||
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<string, unknown>
|
||||
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 = <R>(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ErrorConstructor>([
|
||||
["TypeError", TypeError],
|
||||
["RangeError", RangeError],
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user