fix(codemode): scope promises to executions

This commit is contained in:
Aiden Cline 2026-07-07 15:58:08 -05:00
parent 326dd7e8e7
commit cd64a45512
5 changed files with 155 additions and 146 deletions

View file

@ -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 `{}`.

View file

@ -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. |

View file

@ -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<string> = []): Array<s
return out
}
// Promise work has execution lifetime, while observation only controls whether a settled
// rejection is reported. Awaiting work during final draining must not conflate the two.
class PromiseRuntime<R> {
private readonly active = new Set<SandboxPromise>()
private readonly settled = new Map<SandboxPromise, Exit.Exit<unknown, unknown>>()
private readonly unobserved = new Set<SandboxPromise>()
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
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<Exit.Exit<unknown, unknown>> {
this.unobserved.delete(promise)
return Fiber.await(promise.fiber)
}
drain(): Effect.Effect<void, unknown> {
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<R> {
private scopes: Array<Map<string, Binding>>
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@ -620,24 +669,22 @@ class Interpreter<R> {
private readonly logs: Array<string>
// 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<SandboxPromise>
private readonly promises: PromiseRuntime<R>
constructor(
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promises: PromiseRuntime<R>,
logs: Array<string> = [],
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY),
) {
const globalScope = new Map<string, Binding>()
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<SandboxPromise>()
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<R> {
// 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<void, unknown, never> {
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<R> {
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
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<Exit.Exit<unknown, unknown>> {
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<unknown, unknown, never> {
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<unknown, unknown>,
node?: AstNode,
): Effect.Effect<unknown, unknown> {
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<R> {
// 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<R> {
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<unknown> = []
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<R> {
)
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<R> {
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
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<string, Binding>()]
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 Tools extends Record<string, unknown>>(
})
}
const operation = Effect.gen(function* () {
const program = parseProgram(options.code)
const interpreter = new Interpreter<Services<Tools>>(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<Services<Tools>>(scope)
const interpreter = new Interpreter<Services<Tools>>(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(

View file

@ -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<unknown, unknown> | undefined,
readonly immediate?: Effect.Effect<unknown, unknown>,
) {}
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
}
export class SandboxDate {

View file

@ -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)
})
})