From cd64a4551208c20708df3a5214a3a0a9273cdf80 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 15:58:08 -0500 Subject: [PATCH] fix(codemode): scope promises to executions --- packages/codemode/README.md | 2 +- packages/codemode/codemode.md | 14 +- packages/codemode/src/interpreter/runtime.ts | 207 +++++++++---------- packages/codemode/src/values.ts | 8 +- packages/codemode/test/promise.test.ts | 70 +++++-- 5 files changed, 155 insertions(+), 146 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index e298ccfc57a..e4d92795d15 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). `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 an execution-owned fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `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` leaves losing calls running. At most 8 tool calls run concurrently. When a program completes, still-running promises are awaited before the execution ends without marking their rejections handled; an unobserved failure 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 5e022c2cda2..e579e6ee8f9 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -62,11 +62,13 @@ path lookup, namespace browsing, deterministic ranking, and pagination. ### Tool execution -Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be -awaited directly or through `Promise.all`, `Promise.allSettled`, and `Promise.race`. These combinators also return eager, -run-once sandbox promises, so independent aggregate batches overlap and rejection is observed at the eventual `await`. -`Promise.resolve` and `Promise.reject` use the same tracked lifecycle. At most eight tool calls execute concurrently. -Unfinished promises are drained before successful program completion, and an unhandled failure becomes a diagnostic. +Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls, +async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested +functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection +is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result +wins while losers continue. Before normal completion, CodeMode drains all active promises without marking them observed, +then reports an unobserved rejection as a diagnostic. Timeout or host interruption closes the execution promise scope +and interrupts its active fibers. At most eight tool calls execute concurrently. The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call @@ -133,7 +135,7 @@ represent accurately rather than guessing semantics. | Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. | | Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. | | Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. | -| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. | +| Own eager promises for the whole execution. | This preserves normal call-time parallelism and run-once settlement without tying promise lifetime to a transient async function. | | Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. | | Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. | | Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. | diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 9d9a79c13b4..fed5c1189ff 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,5 +1,5 @@ import { parse } from "acorn" -import { Cause, Effect, Exit, Fiber, Semaphore } from "effect" +import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect" import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" import { copyIn, @@ -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) @@ -611,6 +611,55 @@ const collectPatternNames = (pattern: AstNode, out: Array = []): Array { + private readonly active = new Set() + private readonly settled = new Map>() + private readonly unobserved = new Set() + + constructor(private readonly scope: Scope.Scope) {} + + create(effect: Effect.Effect): Effect.Effect { + return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => { + const promise = new SandboxPromise(fiber) + this.active.add(promise) + this.unobserved.add(promise) + fiber.addObserver((exit) => { + this.active.delete(promise) + this.settled.set(promise, exit) + }) + return promise + }) + } + + observe(promise: SandboxPromise): Effect.Effect> { + this.unobserved.delete(promise) + return Fiber.await(promise.fiber) + } + + drain(): Effect.Effect { + const self = this + return Effect.gen(function* () { + while (self.active.size > 0) { + for (const promise of [...self.active]) yield* Fiber.await(promise.fiber) + } + + for (const promise of self.unobserved) { + const exit = self.settled.get(promise) + if (exit === undefined || Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue + const failure = normalizeError(Cause.squash(exit.cause)) + throw new InterpreterRuntimeError( + `Unhandled rejection from an un-awaited promise: ${failure.message}`, + undefined, + failure.kind, + ["Await promises so failures can be caught and handled."], + ) + } + }) + } +} + class Interpreter { private scopes: Array> private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect @@ -620,24 +669,22 @@ class Interpreter { private readonly logs: Array // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore - // Fiber-backed promises whose settlement no program construct has observed yet. Successful - // program completion drains these (like a runtime waiting on in-flight work at exit) and - // surfaces a never-awaited failure as an unhandled-rejection diagnostic. - private readonly pendingSettlements: Set + private readonly promises: PromiseRuntime constructor( invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, + promises: PromiseRuntime, logs: Array = [], - shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set }, + callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY), ) { const globalScope = new Map() this.scopes = [globalScope] this.invokeTool = invokeTool this.toolKeys = toolKeys this.logs = logs - this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) - this.pendingSettlements = shared?.pendingSettlements ?? new Set() + this.callPermits = callPermits + this.promises = promises globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("undefined", { mutable: false, value: undefined }) @@ -703,36 +750,13 @@ class Interpreter { // resolves before crossing the data boundary - `return tools.ns.tool(...)` works // without an explicit await, exactly as in JS. if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) - yield* self.drainPendingSettlements() + yield* self.promises.drain() return value }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) } - // 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). - private drainPendingSettlements(): Effect.Effect { - const self = this - return Effect.gen(function* () { - while (self.pendingSettlements.size > 0) { - const promise = self.pendingSettlements.values().next().value - if (promise === undefined) break - const exit = yield* self.observePromise(promise) - if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue - const failure = normalizeError(Cause.squash(exit.cause)) - throw new InterpreterRuntimeError( - `Unhandled rejection from an un-awaited promise: ${failure.message}`, - undefined, - failure.kind, - ["Await promises so failures can be caught and handled."], - ) - } - }) - } - - // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and - // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a + // Eagerly starts a tool call in the execution's promise scope (so timeout and teardown + // interrupt it) gated by the concurrency semaphore, and wraps the fiber in a // first-class promise value. `startImmediately` makes the runtime admit the call - charging // the tool-call budget and firing onToolCallStart - at the call site, before any await. private createToolCallPromise( @@ -743,45 +767,28 @@ class Interpreter { } private createPromise(effect: Effect.Effect): Effect.Effect { - return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => { - const promise = new SandboxPromise(fiber) - this.pendingSettlements.add(promise) - return promise - }) + return this.promises.create(effect) } // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice, // Promise.all([p, p])) never re-runs the underlying call. private observePromise(promise: SandboxPromise): Effect.Effect> { - this.pendingSettlements.delete(promise) - return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void) + return this.promises.observe(promise) } // `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 { 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, node)) } private unwrapPromiseExit( - promise: SandboxPromise | undefined, exit: Exit.Exit, node?: AstNode, ): 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) } @@ -2237,8 +2244,8 @@ class Interpreter { // preserve input order when they all fulfill. Rejected calls keep draining siblings. const observations = items.map((item, index) => item instanceof SandboxPromise - ? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit })) - : Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }), + ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) + : Effect.succeed({ index, exit: Exit.succeed(item) }), ) const aggregate = Effect.gen(function* () { const remaining = [...observations] @@ -2252,58 +2259,37 @@ class Interpreter { values[winner.index] = winner.exit.value continue } - return yield* self.unwrapPromiseExit(winner.item, winner.exit, node) + return yield* self.unwrapPromiseExit(winner.exit, node) } return values }) - return Effect.gen(function* () { - const promise = yield* self.createPromise(aggregate) - // Keep observing every member after fail-fast settlement without tying the drain - // fiber to the aggregate fiber, whose completion interrupts its own children. - yield* self.createPromise( - Effect.asVoid( - Effect.forEach( - items, - (item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void), - { concurrency: "unbounded" }, - ), - ), - ) - return promise - }) + return this.createPromise(aggregate) } 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) }), + ? this.observePromise(item) + : Effect.succeed(Exit.succeed(item as unknown)), ) return this.createPromise( 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)), }), ) } @@ -2329,20 +2315,10 @@ class Interpreter { ) return this.createPromise( 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; losing work remains owned by the + // execution scope and is drained before normal completion. 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, - ) + return yield* self.unwrapPromiseExit(winner.exit, node) }), ) } @@ -2350,10 +2326,7 @@ class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, { - callPermits: this.callPermits, - pendingSettlements: this.pendingSettlements, - }) + const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits) invocation.scopes = [...fn.capturedScopes, new Map()] const run = Effect.gen(function* () { // Seed every parameter name into the scope as a TDZ slot first, so a default that @@ -3521,18 +3494,24 @@ export const executeWithLimits = >( }) } - const operation = Effect.gen(function* () { - const program = parseProgram(options.code) - const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) - const value = yield* interpreter.run(program) - const result = copyOut(copyIn(value, "Execution result"), true) as DataValue - return { - ok: true, - value: result, - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }).pipe((program) => { + const operation = Effect.acquireUseRelease( + Scope.make("parallel"), + (scope) => + Effect.gen(function* () { + const program = parseProgram(options.code) + const promises = new PromiseRuntime>(scope) + const interpreter = new Interpreter>(tools.invoke, tools.keys, promises, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + return { + ok: true, + value: result, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }), + (scope, exit) => Scope.close(scope, exit), + ).pipe((program) => { const timeoutMs = limits.timeoutMs if (timeoutMs === undefined) return program return program.pipe( diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 4ca305d815e..3ba2421d5d9 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -1,11 +1,7 @@ -import type { Effect, Fiber } from "effect" +import type { Fiber } from "effect" export class SandboxPromise { - interrupted = false - constructor( - readonly fiber: Fiber.Fiber | undefined, - readonly immediate?: Effect.Effect, - ) {} + constructor(readonly fiber: Fiber.Fiber) {} } export class SandboxDate { diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 4574f952548..683644a7cb7 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -235,6 +235,26 @@ describe("first-class promise values", () => { expect(diagnostic.kind).toBe("ToolFailure") expect(diagnostic.message).toContain("Lookup refused") }) + + test("async-function promises remain owned by the execution after the function returns", async () => { + const trace = makeTrace() + expect( + await value( + ` + const launch = async () => { + tools.host.sleepy({ id: 1, ms: 40 }) + Promise.all([tools.host.sleepy({ id: 2, ms: 40 })]) + return "returned" + } + return await launch() + `, + { trace }, + ), + ).toBe("returned") + expect(trace.starts).toEqual([1, 2]) + expect(trace.completed).toBe(2) + expect(trace.interrupted).toBe(0) + }) }) describe("promises at data boundaries", () => { @@ -495,45 +515,55 @@ describe("Promise.allSettled", () => { }) describe("Promise.race", () => { - test("first settlement wins and losers are interrupted", async () => { + test("first settlement wins and a direct loser completes during final draining", 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: 40 }) return await Promise.race([fast, slow]) `, { trace }, ) expect(result).toBe(1) - expect(trace.interrupted).toBe(1) - expect(trace.completed).toBe(1) + expect(trace.completed).toBe(2) + expect(trace.interrupted).toBe(0) }) - test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { + test("a direct loser remains awaitable after the race settles", async () => { expect( 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: 40 }) const winner = await Promise.race([fast, slow]) - try { - await slow - return "no" - } catch (e) { - return { winner, caught: e.message } - } + return { winner, loser: await slow } `), - ).toEqual({ - winner: 1, - caught: "This tool call was interrupted because another value settled a Promise.race first.", - }) + ).toEqual({ winner: 1, loser: 2 }) + }) + + test("a nested aggregate loser and its members complete during final draining", async () => { + const trace = makeTrace() + expect( + await value( + ` + const nested = Promise.all([ + tools.host.sleepy({ id: 1, ms: 40 }), + tools.host.sleepy({ id: 2, ms: 40 }), + ]) + return await Promise.race(["immediate", nested]) + `, + { trace }, + ), + ).toBe("immediate") + expect(trace.completed).toBe(2) + expect(trace.interrupted).toBe(0) }) 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: 40 })]) return "no" } catch (e) { return e.message @@ -545,9 +575,10 @@ 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: 40 }), "immediate"])`, { trace }), ).toBe("immediate") - expect(trace.interrupted).toBe(1) + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) }) test("an empty race is a clear error instead of hanging", async () => { @@ -587,6 +618,7 @@ describe("Promise.resolve / Promise.reject", () => { expect(diagnostic.kind).toBe("ExecutionFailure") expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") expect(diagnostic.message).toContain("abandoned") + expect(diagnostic.message.split("Unhandled rejection from an un-awaited promise").length - 1).toBe(1) }) })