From c3b9d03bdb2c54d54af49d1f1583c42bc59e952d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 17:24:00 -0500 Subject: [PATCH] fix(codemode): preserve promise race losers --- packages/codemode/README.md | 2 +- packages/codemode/codemode.md | 2 +- packages/codemode/src/interpreter/runtime.ts | 75 +++++++------------- packages/codemode/src/values.ts | 1 - packages/codemode/test/promise.test.ts | 46 ++++++------ 5 files changed, 53 insertions(+), 73 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 17715b9a2f..27358e7807 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -246,7 +246,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention. -- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). Promises support `then`/`catch`/`finally`, including returned-promise flattening and rejection recovery. `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. +- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). Promises support `then`/`catch`/`finally`, including returned-promise flattening and rejection recovery. `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` lets losing promises continue. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. - `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 45ebbfa5aa..973d933e93 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -152,7 +152,7 @@ current omissions to implement, not intentional product boundaries. - [ ] Decide whether thenable assimilation belongs in the bounded runtime. Promise resolution currently unwraps only `SandboxPromise`; arbitrary `{ then(resolve, reject) }` values remain data. Full assimilation requires internal callable resolver values, first-settlement arbitration, recursive adoption, and cycle detection. -- [ ] Propagate cancellation through chained promises, and consider `Promise.any`. +- [ ] Consider `Promise.any`. - [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and collection values, then extend it to bounded host streams when a stream boundary exists. - [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 9a29df1cc1..3b5833b906 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -219,7 +219,7 @@ const normalizeError = (error: unknown): Diagnostic => { } } -// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. +// Shared by catch bindings and Promise.allSettled rejection reasons. const caughtErrorValue = (thrown: unknown): unknown => { if (thrown instanceof ProgramThrow) return thrown.value if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) @@ -703,7 +703,7 @@ class Interpreter { // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so // their work completes before the execution ends - mirroring a JS runtime waiting on // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection - // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). + // diagnostic. private drainPendingSettlements(): Effect.Effect { const self = this return Effect.gen(function* () { @@ -752,28 +752,13 @@ class Interpreter { // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch // observes it exactly like a synchronous throw at the await site. - private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { + private settlePromise(promise: SandboxPromise): Effect.Effect { const self = this - return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) + return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(exit)) } - private unwrapPromiseExit( - promise: SandboxPromise | undefined, - exit: Exit.Exit, - node?: AstNode, - ): Effect.Effect { + private unwrapPromiseExit(exit: Exit.Exit): Effect.Effect { if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) - // A call Promise.race interrupted after losing settles as a catchable program failure; - // any other interruption is execution teardown (timeout/host) and must keep propagating - // as interruption rather than becoming program-visible data. - if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { - return Effect.fail( - new InterpreterRuntimeError( - "This tool call was interrupted because another value settled a Promise.race first.", - node, - ), - ) - } return Effect.failCause(exit.cause) } @@ -1533,7 +1518,7 @@ class Interpreter { // matching real JS semantics for non-thenables. const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), + value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value), ) } case "NewExpression": @@ -2257,42 +2242,33 @@ class Interpreter { ), ), ) - return yield* self.unwrapPromiseExit(winner.item, winner.exit, node) + return yield* self.unwrapPromiseExit(winner.exit) } return values }) } case "allSettled": { const observations = items.map((item) => - item instanceof SandboxPromise - ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) - : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), + item instanceof SandboxPromise ? this.observePromise(item) : Effect.succeed(Exit.succeed(item as unknown)), ) return Effect.gen(function* () { const outcomes: Array = [] for (const observation of observations) { - const { exit, promise } = yield* observation + const exit = yield* observation if (Exit.isSuccess(exit)) { outcomes.push( Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), ) continue } - const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) - if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { + if (Cause.hasInterruptsOnly(exit.cause)) { // Execution teardown (timeout/host interruption), not a program-level rejection. return yield* Effect.failCause(exit.cause) } - const thrown = raceInterrupted - ? new InterpreterRuntimeError( - "This tool call was interrupted because another value settled a Promise.race first.", - node, - ) - : Cause.squash(exit.cause) outcomes.push( Object.assign(Object.create(null) as SafeObject, { status: "rejected", - reason: caughtErrorValue(thrown), + reason: caughtErrorValue(Cause.squash(exit.cause)), }), ) } @@ -2312,20 +2288,21 @@ class Interpreter { : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), ) return Effect.gen(function* () { - // First settlement (fulfilled OR rejected) wins; the observations never fail, so - // racing them yields exactly that. Losing in-flight calls are then interrupted. + // First settlement (fulfilled OR rejected) wins. Losers continue like native + // promises, while a supervised drain keeps their failures handled and their work + // inside the execution lifetime. const winner = yield* Effect.raceAll(observations) - for (const [index, item] of items.entries()) { - if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue - item.interrupted = true - yield* Fiber.interrupt(item.fiber) - } - const winningItem = items[winner.index] - return yield* self.unwrapPromiseExit( - winningItem instanceof SandboxPromise ? winningItem : undefined, - winner.exit, - node, + yield* self.createPromise( + Effect.asVoid( + Effect.forEach( + items, + (item, index) => + index !== winner.index && item instanceof SandboxPromise ? self.observePromise(item) : Effect.void, + { concurrency: "unbounded" }, + ), + ), ) + return yield* self.unwrapPromiseExit(winner.exit) }) } } @@ -2469,7 +2446,7 @@ class Interpreter { new InterpreterRuntimeError("Chaining cycle detected for promise.", node).as("TypeError"), ) } - return result instanceof SandboxPromise ? this.settlePromise(result, node) : Effect.succeed(result) + return result instanceof SandboxPromise ? this.settlePromise(result) : Effect.succeed(result) }, ) } @@ -2477,7 +2454,7 @@ class Interpreter { const onFulfilled = name === "then" ? callback(args[0]) : undefined const onRejected = name === "then" ? callback(args[1]) : name === "catch" ? callback(args[0]) : undefined const onFinally = name === "finally" ? callback(args[0]) : undefined - const settlement = Effect.exit(this.settlePromise(promise, node)) + const settlement = Effect.exit(this.settlePromise(promise)) return Effect.map( this.createPromise( diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 4ca305d815..101ccd8377 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -1,7 +1,6 @@ import type { Effect, Fiber } from "effect" export class SandboxPromise { - interrupted = false constructor( readonly fiber: Fiber.Fiber | undefined, readonly immediate?: Effect.Effect, diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 931e246ea9..e364f39076 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -412,45 +412,36 @@ describe("Promise.allSettled", () => { }) describe("Promise.race", () => { - test("first settlement wins and losers are interrupted", async () => { + test("first settlement wins and losers continue", async () => { const trace = makeTrace() const result = await value( ` const fast = tools.host.sleepy({ id: 1, ms: 10 }) - const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + const slow = tools.host.sleepy({ id: 2, ms: 30 }) return await Promise.race([fast, slow]) `, { trace }, ) expect(result).toBe(1) - expect(trace.interrupted).toBe(1) - expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + expect(trace.completed).toBe(2) }) - test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { + test("a losing chain continues and remains awaitable", async () => { expect( await value(` - const fast = tools.host.sleepy({ id: 1, ms: 10 }) - const slow = tools.host.sleepy({ id: 2, ms: 5000 }) - const winner = await Promise.race([fast, slow]) - try { - await slow - return "no" - } catch (e) { - return { winner, caught: e.message } - } + const slow = tools.host.sleepy({ id: 2, ms: 30 }).then((id) => id * 2) + const winner = await Promise.race([slow, "fast"]) + return [winner, await slow] `), - ).toEqual({ - winner: 1, - caught: "This tool call was interrupted because another value settled a Promise.race first.", - }) + ).toEqual(["fast", 4]) }) test("a rejection can win the race", async () => { expect( await value(` try { - await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) + await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 30 })]) return "no" } catch (e) { return e.message @@ -462,9 +453,22 @@ describe("Promise.race", () => { test("a plain value wins over pending promises", async () => { const trace = makeTrace() expect( - await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }), + await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 30 }), "immediate"])`, { trace }), ).toBe("immediate") - expect(trace.interrupted).toBe(1) + expect(trace.interrupted).toBe(0) + expect(trace.completed).toBe(1) + }) + + test("a losing rejection remains handled", async () => { + expect( + await value(` + const rejectLater = async () => { + await tools.host.sleepy({ id: 1, ms: 20 }) + throw new Error("late") + } + return await Promise.race([rejectLater(), "winner"]) + `), + ).toBe("winner") }) test("an empty race is a clear error instead of hanging", async () => {