mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-28 20:53:14 +00:00
1664 lines
58 KiB
TypeScript
1664 lines
58 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { Cause, Deferred, Effect, Exit, Schema } from "effect"
|
|
import { CodeMode, Tool, toolError } from "../src/index.js"
|
|
|
|
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly as
|
|
// host work, `await` settles them, Promise.all/allSettled/race/resolve/reject are
|
|
// ordinary functions over arbitrary arrays mixing promises and plain values, and
|
|
// .then/.catch/.finally chain reactions onto any promise.
|
|
|
|
type Trace = {
|
|
starts: Array<number>
|
|
active: number
|
|
maxActive: number
|
|
completed: number
|
|
interrupted: number
|
|
}
|
|
|
|
const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
|
|
|
|
/**
|
|
* Deterministic tool set: ordering and interruption are structural, never temporal.
|
|
*
|
|
* - `echo` settles immediately with its id.
|
|
* - `gated` blocks until `open` releases the same id. Tool fibers start eagerly at the
|
|
* call site, so several gated calls are provably live at once before any `open` runs.
|
|
* - `pending` never settles; tests assert its interruption instead of racing a timer.
|
|
*
|
|
* Real clocks remain only in the wall-clock timeout tests (`timeoutMs`, `stubborn`
|
|
* cleanup), where elapsed time is the behavior under test.
|
|
*/
|
|
const echoTool = (trace: Trace) =>
|
|
Tool.make({
|
|
description: "Echo an id immediately",
|
|
input: Schema.Struct({ id: Schema.Number }),
|
|
output: Schema.Number,
|
|
execute: ({ id }) =>
|
|
Effect.sync(() => {
|
|
trace.starts.push(id)
|
|
trace.completed += 1
|
|
return id
|
|
}),
|
|
})
|
|
|
|
const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>) =>
|
|
Tool.make({
|
|
description: "Echo an id once its gate opens",
|
|
input: Schema.Struct({ id: Schema.Number }),
|
|
output: Schema.Number,
|
|
execute: ({ id }) =>
|
|
Effect.gen(function* () {
|
|
trace.starts.push(id)
|
|
trace.active += 1
|
|
trace.maxActive = Math.max(trace.maxActive, trace.active)
|
|
yield* Deferred.await(gate(id))
|
|
trace.active -= 1
|
|
trace.completed += 1
|
|
return id
|
|
}).pipe(
|
|
Effect.onInterrupt(() =>
|
|
Effect.sync(() => {
|
|
trace.active -= 1
|
|
trace.interrupted += 1
|
|
}),
|
|
),
|
|
),
|
|
})
|
|
|
|
const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
|
|
Tool.make({
|
|
description: "Open the gate for an id",
|
|
input: Schema.Struct({ id: Schema.Number }),
|
|
output: Schema.Boolean,
|
|
execute: ({ id }) => Deferred.succeed(gate(id), undefined),
|
|
})
|
|
|
|
const pendingTool = (trace: Trace) =>
|
|
Tool.make({
|
|
description: "Never settle",
|
|
input: Schema.Struct({ id: Schema.Number }),
|
|
output: Schema.Number,
|
|
execute: ({ id }) =>
|
|
Effect.gen(function* () {
|
|
trace.starts.push(id)
|
|
trace.active += 1
|
|
trace.maxActive = Math.max(trace.maxActive, trace.active)
|
|
return yield* Effect.never
|
|
}).pipe(
|
|
Effect.onInterrupt(() =>
|
|
Effect.sync(() => {
|
|
trace.active -= 1
|
|
trace.interrupted += 1
|
|
}),
|
|
),
|
|
),
|
|
})
|
|
|
|
const failingTool = Tool.make({
|
|
description: "Always refuse",
|
|
input: Schema.Struct({}),
|
|
output: Schema.String,
|
|
execute: () => Effect.fail(toolError("Lookup refused")),
|
|
})
|
|
|
|
const interruptedTool = Tool.make({
|
|
description: "Interrupt this call",
|
|
input: Schema.Struct({}),
|
|
output: Schema.String,
|
|
execute: () => Effect.interrupt,
|
|
})
|
|
|
|
const completedTool = (trace: Trace) =>
|
|
Tool.make({
|
|
description: "Return the number of completed calls",
|
|
input: Schema.Struct({}),
|
|
output: Schema.Number,
|
|
execute: () => Effect.succeed(trace.completed),
|
|
})
|
|
|
|
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
|
|
const stubbornTool = (trace: Trace) =>
|
|
Tool.make({
|
|
description: "Never settle; clean up slowly when interrupted",
|
|
input: Schema.Struct({ cleanupMs: Schema.Number }),
|
|
output: Schema.Number,
|
|
execute: ({ cleanupMs }) =>
|
|
Effect.never.pipe(
|
|
Effect.onInterrupt(() =>
|
|
Effect.andThen(
|
|
Effect.sleep(cleanupMs),
|
|
Effect.sync(() => {
|
|
trace.interrupted += 1
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
})
|
|
|
|
const run = (
|
|
code: string,
|
|
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
|
|
): Promise<CodeMode.Result> => {
|
|
const trace = options.trace ?? makeTrace()
|
|
const gates = new Map<number, Deferred.Deferred<void>>()
|
|
const gate = (id: number): Deferred.Deferred<void> => {
|
|
const existing = gates.get(id)
|
|
if (existing) return existing
|
|
const created = Deferred.makeUnsafe<void>()
|
|
gates.set(id, created)
|
|
return created
|
|
}
|
|
return Effect.runPromise(
|
|
CodeMode.execute({
|
|
tools: {
|
|
host: {
|
|
echo: echoTool(trace),
|
|
gated: gatedTool(trace, gate),
|
|
open: openTool(gate),
|
|
pending: pendingTool(trace),
|
|
fail: failingTool,
|
|
interrupt: interruptedTool,
|
|
completed: completedTool(trace),
|
|
stubborn: stubbornTool(trace),
|
|
},
|
|
},
|
|
code,
|
|
...(options.limits ? { limits: options.limits } : {}),
|
|
}),
|
|
)
|
|
}
|
|
|
|
const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
|
|
const result = await run(code, options)
|
|
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
|
return result.value
|
|
}
|
|
|
|
const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
|
|
const result = await run(code, options)
|
|
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
|
|
return result.error
|
|
}
|
|
|
|
describe("first-class promise values", () => {
|
|
test("async functions return promises with isolated concurrent invocations", async () => {
|
|
expect(
|
|
await value(`
|
|
const load = async (id) => {
|
|
const result = await tools.host.echo({ id })
|
|
return [id, result]
|
|
}
|
|
const first = load(1)
|
|
const second = load(2)
|
|
return [first instanceof Promise, second instanceof Promise, await Promise.all([first, second])]
|
|
`),
|
|
).toEqual([
|
|
true,
|
|
true,
|
|
[
|
|
[1, 1],
|
|
[2, 2],
|
|
],
|
|
])
|
|
})
|
|
|
|
test("async function errors reject instead of throwing at the call site", async () => {
|
|
expect(
|
|
await value(`
|
|
const fail = async () => { throw new Error("boom") }
|
|
const promise = fail()
|
|
try {
|
|
await promise
|
|
return "no"
|
|
} catch (error) {
|
|
return error.message
|
|
}
|
|
`),
|
|
).toBe("boom")
|
|
})
|
|
|
|
test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
|
|
const trace = makeTrace()
|
|
const result = await value(
|
|
`
|
|
const a = tools.host.gated({ id: 1 })
|
|
const b = tools.host.gated({ id: 2 })
|
|
await tools.host.open({ id: 1 })
|
|
await tools.host.open({ id: 2 })
|
|
const rb = await b
|
|
const ra = await a
|
|
return [ra, rb]
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result).toEqual([1, 2])
|
|
expect(trace.starts).toEqual([1, 2])
|
|
// Both calls overlapped even though they were awaited sequentially.
|
|
expect(trace.maxActive).toBeGreaterThan(1)
|
|
})
|
|
|
|
test("awaiting the same promise twice settles once and never re-runs the call", async () => {
|
|
const result = await run(`
|
|
const p = tools.host.echo({ id: 7 })
|
|
const x = await p
|
|
const y = await p
|
|
return [x, y]
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toEqual([7, 7])
|
|
expect(result.toolCalls).toStrictEqual([{ name: "host.echo" }])
|
|
})
|
|
|
|
test("await of a non-promise value passes it through unchanged", async () => {
|
|
expect(await value(`return await 42`)).toBe(42)
|
|
expect(await value(`const x = await "s"; return x`)).toBe("s")
|
|
expect(await value(`return await null`)).toBeNull()
|
|
expect(await value(`return (await [1, 2]).length`)).toBe(2)
|
|
})
|
|
|
|
test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
|
|
expect(await value(`return tools.host.echo({ id: 9 })`)).toBe(9)
|
|
})
|
|
|
|
test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
|
|
const result = await run(`
|
|
const p = Promise.resolve(1)
|
|
console.log(p)
|
|
return typeof p
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("object")
|
|
expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"])
|
|
})
|
|
|
|
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
|
|
const result = await run(`
|
|
const p = tools.host.fail({})
|
|
try {
|
|
await p
|
|
return "no"
|
|
} catch (e) {
|
|
return e.message
|
|
}
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("Lookup refused")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("a fire-and-forget call is interrupted when the program returns", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
tools.host.pending({ id: 1 })
|
|
return "done"
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toBeUndefined()
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("a never-awaited failing call preserves the result and reports the rejection", async () => {
|
|
const result = await run(`
|
|
tools.host.fail({})
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
|
|
])
|
|
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
|
|
})
|
|
|
|
test("a never-awaited failing async function is reported with a successful result", async () => {
|
|
const result = await run(`
|
|
const fail = async () => { throw new Error("boom") }
|
|
fail()
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
|
|
])
|
|
})
|
|
|
|
test("output truncation bounds warning diagnostics with an in-band marker", async () => {
|
|
const result = await run(
|
|
`
|
|
for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
|
|
return "done"
|
|
`,
|
|
{ limits: { maxOutputBytes: 64 } },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "Truncated", message: "100 additional warnings omitted by the output limit." },
|
|
])
|
|
})
|
|
|
|
test("a budget-consuming value does not starve warnings", async () => {
|
|
const result = await run(
|
|
`
|
|
Promise.reject(new Error("boom"))
|
|
return "x".repeat(500)
|
|
`,
|
|
{ limits: { maxOutputBytes: 128 } },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.truncated).toBe(true)
|
|
expect(typeof result.value).toBe("string")
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
|
|
])
|
|
})
|
|
|
|
test("an un-awaited async function's pending chain is interrupted at the return", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
const run = async () => {
|
|
await tools.host.pending({ id: 1 })
|
|
tools.host.fail({})
|
|
}
|
|
run()
|
|
return "done"
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toBeUndefined()
|
|
expect(trace.starts).toEqual([1])
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("reports every unhandled rejection in promise creation order", async () => {
|
|
const result = await run(`
|
|
Promise.reject(new Error("first"))
|
|
tools.host.fail({})
|
|
Promise.reject(new Error("third"))
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: first" },
|
|
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: third" },
|
|
])
|
|
})
|
|
|
|
test("orders an async function rejection before promises created inside its body", async () => {
|
|
const result = await run(`
|
|
const outer = async () => {
|
|
Promise.reject(new Error("inner"))
|
|
throw new Error("outer")
|
|
}
|
|
outer()
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: outer" },
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: inner" },
|
|
])
|
|
})
|
|
|
|
test("un-awaited interruptions settle without becoming rejections", async () => {
|
|
const result = await run(`
|
|
tools.host.interrupt({})
|
|
Promise.all([tools.host.interrupt({})])
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("a fatal program error cancels outstanding work without reporting unhandled rejections", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
tools.host.pending({ id: 1 })
|
|
throw new Error("boom")
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.error.message).toBe("Error: boom")
|
|
expect("warnings" in result).toBe(false)
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
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.pending({ id: 1 })
|
|
Promise.all([tools.host.pending({ id: 2 })])
|
|
return "returned"
|
|
}
|
|
return await launch()
|
|
`,
|
|
{ trace },
|
|
),
|
|
).toBe("returned")
|
|
// Both calls outlive launch() itself - they belong to the execution, not the function -
|
|
// and are interrupted only when the whole program returns.
|
|
expect(trace.starts).toEqual([1, 2])
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe("promises at data boundaries", () => {
|
|
test("an un-awaited promise inside a result or tool argument is awaited", async () => {
|
|
expect(await value(`return { result: tools.host.echo({ id: 1 }) }`)).toEqual({ result: 1 })
|
|
expect(await value(`return Array.from([Promise.resolve(1)])`)).toEqual([1])
|
|
expect(await value(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`)).toBe(1)
|
|
})
|
|
|
|
test("a rejected promise inside a result fails the program with its reason", async () => {
|
|
const diagnostic = await error(`return { result: tools.host.fail({}) }`)
|
|
expect(diagnostic.kind).toBe("ToolFailure")
|
|
expect(diagnostic.message).toContain("Lookup refused")
|
|
})
|
|
|
|
test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => {
|
|
const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`)
|
|
expect(diagnostic.kind).toBe("InvalidDataValue")
|
|
expect(diagnostic.message).toContain("un-awaited Promise")
|
|
})
|
|
|
|
test("returning a never-settling promise inside data waits until the timeout", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
const pending = tools.host.pending({ id: 1 })
|
|
return { pending }
|
|
`,
|
|
{ trace, limits: { timeoutMs: 100 } },
|
|
)
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.error.kind).toBe("TimeoutExceeded")
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("operators reject promise operands", async () => {
|
|
const diagnostic = await error(`return Promise.resolve(1) + 1`)
|
|
expect(diagnostic.kind).toBe("InvalidDataValue")
|
|
})
|
|
})
|
|
|
|
describe("Promise.all over arbitrary arrays", () => {
|
|
test("combinators return promises that can be assigned and awaited later", async () => {
|
|
expect(
|
|
await value(`
|
|
const all = Promise.all([Promise.resolve(1)])
|
|
const settled = Promise.allSettled([Promise.reject("no")])
|
|
const race = Promise.race([Promise.resolve(2)])
|
|
const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
|
|
return [promises, await all, await settled, await race]
|
|
`),
|
|
).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
|
|
})
|
|
|
|
test("separately-created aggregate batches overlap before either is awaited", async () => {
|
|
const trace = makeTrace()
|
|
expect(
|
|
await value(
|
|
`
|
|
const first = Promise.all([tools.host.gated({ id: 1 })])
|
|
const second = Promise.all([tools.host.gated({ id: 2 })])
|
|
await tools.host.open({ id: 1 })
|
|
await tools.host.open({ id: 2 })
|
|
return [await first, await second]
|
|
`,
|
|
{ trace },
|
|
),
|
|
).toEqual([[1], [2]])
|
|
expect(trace.starts).toEqual([1, 2])
|
|
expect(trace.maxActive).toBeGreaterThan(1)
|
|
})
|
|
|
|
test("an aggregate created before a try block rejects at its later await", async () => {
|
|
expect(
|
|
await value(`
|
|
const aggregate = Promise.all([tools.host.fail({})])
|
|
try {
|
|
await aggregate
|
|
return "no"
|
|
} catch (error) {
|
|
return error.message
|
|
}
|
|
`),
|
|
).toBe("Lookup refused")
|
|
})
|
|
|
|
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
|
|
const result = await run(`
|
|
const aggregate = Promise.all([tools.host.echo({ id: 7 })])
|
|
return [await aggregate, await aggregate]
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toEqual([[7], [7]])
|
|
expect(result.toolCalls).toStrictEqual([{ name: "host.echo" }])
|
|
})
|
|
|
|
test("mixes promises and plain values, preserving order", async () => {
|
|
expect(
|
|
await value(`
|
|
return await Promise.all([tools.host.echo({ id: 1 }), "plain", tools.host.echo({ id: 2 }), 42])
|
|
`),
|
|
).toEqual([1, "plain", 2, 42])
|
|
})
|
|
|
|
test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
|
|
expect(
|
|
await value(`
|
|
const calls = []
|
|
calls.push(tools.host.echo({ id: 1 }))
|
|
calls.push(7)
|
|
const more = [tools.host.echo({ id: 2 })]
|
|
const batch = [...calls, ...more, "x"]
|
|
return await Promise.all(batch)
|
|
`),
|
|
).toEqual([1, 7, 2, "x"])
|
|
})
|
|
|
|
test("runs items.map tool calls in parallel", async () => {
|
|
const trace = makeTrace()
|
|
const result = await value(
|
|
`
|
|
const ids = [1, 2, 3, 4]
|
|
const calls = ids.map((id) => tools.host.gated({ id }))
|
|
for (const id of ids) await tools.host.open({ id })
|
|
return await Promise.all(calls)
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result).toEqual([1, 2, 3, 4])
|
|
// maxActive counts truly-overlapping live executions, so > 1 proves real
|
|
// parallelism deterministically - no wall-clock assertion needed.
|
|
expect(trace.maxActive).toBeGreaterThan(1)
|
|
})
|
|
|
|
test("runs async map callbacks concurrently", async () => {
|
|
const trace = makeTrace()
|
|
const result = await value(
|
|
`
|
|
const ids = [1, 2, 3, 4]
|
|
const calls = ids.map(async (id) => await tools.host.gated({ id }))
|
|
for (const id of ids) await tools.host.open({ id })
|
|
return await Promise.all(calls)
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result).toEqual([1, 2, 3, 4])
|
|
expect(trace.maxActive).toBeGreaterThan(1)
|
|
})
|
|
|
|
test("does not cap live tool-call concurrency", async () => {
|
|
const trace = makeTrace()
|
|
const result = await value(
|
|
`
|
|
const ids = []
|
|
for (let i = 0; i < 20; i += 1) ids.push(i)
|
|
const calls = ids.map((id) => tools.host.gated({ id }))
|
|
for (const id of ids) await tools.host.open({ id })
|
|
const results = await Promise.all(calls)
|
|
return results.length
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result).toBe(20)
|
|
expect(trace.maxActive).toBe(20)
|
|
})
|
|
|
|
test("resolves the empty array", async () => {
|
|
expect(await value(`return await Promise.all([])`)).toEqual([])
|
|
})
|
|
|
|
test("rejects with the first failure, catchable in-program", async () => {
|
|
const result = await run(`
|
|
try {
|
|
await Promise.all([tools.host.echo({ id: 1 }), tools.host.fail({})])
|
|
return "no"
|
|
} catch (e) {
|
|
return e.message
|
|
}
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("Lookup refused")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("rejects before an earlier slow promise fulfills", async () => {
|
|
const trace = makeTrace()
|
|
expect(
|
|
await value(
|
|
`
|
|
try {
|
|
await Promise.all([
|
|
tools.host.pending({ id: 1 }),
|
|
tools.host.fail({}),
|
|
])
|
|
return -1
|
|
} catch {
|
|
return await tools.host.completed({})
|
|
}
|
|
`,
|
|
{ trace },
|
|
),
|
|
).toBe(0)
|
|
// The surviving member is observed (Promise.all handled it), so completion interrupts
|
|
// it instead of waiting for it.
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("fail-fast does not cancel a sibling the program still holds and awaits", async () => {
|
|
const trace = makeTrace()
|
|
expect(
|
|
await value(
|
|
`
|
|
const slow = tools.host.gated({ id: 1 })
|
|
try {
|
|
await Promise.all([slow, tools.host.fail({})])
|
|
return "no"
|
|
} catch {}
|
|
await tools.host.open({ id: 1 })
|
|
return await slow
|
|
`,
|
|
{ trace },
|
|
),
|
|
).toBe(1)
|
|
expect(trace.completed).toBe(1)
|
|
expect(trace.interrupted).toBe(0)
|
|
})
|
|
|
|
test("a slower observed sibling is interrupted at completion after failing fast", async () => {
|
|
const trace = makeTrace()
|
|
expect(
|
|
await value(
|
|
`
|
|
const failLater = async () => {
|
|
await tools.host.pending({ id: 1 })
|
|
throw new Error("later")
|
|
}
|
|
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
|
|
try {
|
|
await aggregate
|
|
return "no"
|
|
} catch (error) {
|
|
return error.message
|
|
}
|
|
`,
|
|
{ trace },
|
|
),
|
|
).toBe("first")
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("a non-collection argument is a clear error", async () => {
|
|
const diagnostic = await error(`return await Promise.all(42)`)
|
|
expect(diagnostic.message).toContain("Promise.all expects a synchronous iterable, received a number")
|
|
})
|
|
|
|
test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
|
|
const diagnostic = await error(
|
|
`return await Promise.all([tools.host.echo({ id: 1 }), tools.host.echo({ id: 2 }), tools.host.echo({ id: 3 })])`,
|
|
{ limits: { maxToolCalls: 2 } },
|
|
)
|
|
expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
|
|
})
|
|
})
|
|
|
|
describe("Promise.allSettled", () => {
|
|
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
|
|
expect(
|
|
await value(`
|
|
const settled = await Promise.allSettled([
|
|
tools.host.echo({ id: 5 }),
|
|
tools.host.fail({}),
|
|
"plain",
|
|
Promise.reject(new Error("boom")),
|
|
])
|
|
return settled
|
|
`),
|
|
).toEqual([
|
|
{ status: "fulfilled", value: 5 },
|
|
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
|
|
{ status: "fulfilled", value: "plain" },
|
|
{ status: "rejected", reason: { name: "Error", message: "boom" } },
|
|
])
|
|
})
|
|
|
|
test("never rejects for program-level failures", async () => {
|
|
const result = await run(`
|
|
const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})])
|
|
return settled.filter((s) => s.status === "rejected").length
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe(2)
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("Promise.race", () => {
|
|
test("first settlement wins and a direct loser is interrupted at completion", async () => {
|
|
const trace = makeTrace()
|
|
const result = await value(
|
|
`
|
|
const fast = tools.host.echo({ id: 1 })
|
|
const slow = tools.host.pending({ id: 2 })
|
|
return await Promise.race([fast, slow])
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result).toBe(1)
|
|
// The loser is observed (the race handled it), so the execution does not wait for it.
|
|
expect(trace.completed).toBe(1)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("a direct loser remains awaitable after the race settles", async () => {
|
|
expect(
|
|
await value(`
|
|
const fast = tools.host.echo({ id: 1 })
|
|
const slow = tools.host.gated({ id: 2 })
|
|
const winner = await Promise.race([fast, slow])
|
|
await tools.host.open({ id: 2 })
|
|
return { winner, loser: await slow }
|
|
`),
|
|
).toEqual({ winner: 1, loser: 2 })
|
|
})
|
|
|
|
test("a nested aggregate loser and its members are interrupted at completion", async () => {
|
|
const trace = makeTrace()
|
|
expect(
|
|
await value(
|
|
`
|
|
const nested = Promise.all([
|
|
tools.host.pending({ id: 1 }),
|
|
tools.host.pending({ id: 2 }),
|
|
])
|
|
return await Promise.race(["immediate", nested])
|
|
`,
|
|
{ trace },
|
|
),
|
|
).toBe("immediate")
|
|
// The nested aggregate and its members are all observed, so nothing waits for them.
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(2)
|
|
})
|
|
|
|
test("a rejection can win the race", async () => {
|
|
expect(
|
|
await value(`
|
|
try {
|
|
await Promise.race([tools.host.fail({}), tools.host.pending({ id: 1 })])
|
|
return "no"
|
|
} catch (e) {
|
|
return e.message
|
|
}
|
|
`),
|
|
).toBe("Lookup refused")
|
|
})
|
|
|
|
test("a plain value wins over pending promises", async () => {
|
|
const trace = makeTrace()
|
|
expect(await value(`return await Promise.race([tools.host.pending({ id: 1 }), "immediate"])`, { trace })).toBe(
|
|
"immediate",
|
|
)
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("a rejected race loser is observed by the aggregate", async () => {
|
|
const result = await run(`return await Promise.race(["winner", tools.host.fail({})])`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("winner")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("an empty race is a clear error instead of hanging", async () => {
|
|
const diagnostic = await error(`return await Promise.race([])`)
|
|
expect(diagnostic.message).toContain("never settle")
|
|
})
|
|
})
|
|
|
|
describe("Promise.resolve / Promise.reject", () => {
|
|
test("resolve wraps plain values and passes promises through", async () => {
|
|
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
|
|
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
|
|
expect(await value(`return await Promise.resolve(tools.host.echo({ id: 3 }))`)).toBe(3)
|
|
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
|
|
true,
|
|
)
|
|
})
|
|
|
|
test("reject produces a promise whose await throws the reason", async () => {
|
|
expect(
|
|
await value(`
|
|
try {
|
|
await Promise.reject("nope")
|
|
return "no"
|
|
} catch (e) {
|
|
return e
|
|
}
|
|
`),
|
|
).toBe("nope")
|
|
})
|
|
|
|
test("a rejection observed after settlement is handled", async () => {
|
|
expect(
|
|
await value(`
|
|
const rejected = Promise.reject(new Error("handled"))
|
|
await tools.host.echo({ id: 1 })
|
|
try {
|
|
await rejected
|
|
return "no"
|
|
} catch (error) {
|
|
return error.message
|
|
}
|
|
`),
|
|
).toBe("handled")
|
|
})
|
|
|
|
test("an abandoned rejected promise is reported as unhandled", async () => {
|
|
const result = await run(`
|
|
Promise.reject(new Error("abandoned"))
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: abandoned" },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("timeout interruption of forked calls", () => {
|
|
test("the execution timeout interrupts in-flight forked fibers", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
const a = tools.host.pending({ id: 1 })
|
|
const b = tools.host.pending({ id: 2 })
|
|
return await a
|
|
`,
|
|
{ trace, limits: { timeoutMs: 100 } },
|
|
)
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.error.kind).toBe("TimeoutExceeded")
|
|
// Both calls started; neither escaped the timeout - the awaited one AND the abandoned one.
|
|
expect(trace.starts).toEqual([1, 2])
|
|
expect(trace.interrupted).toBe(2)
|
|
expect(trace.completed).toBe(0)
|
|
})
|
|
|
|
test("the timeout also interrupts calls inside Promise.all", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`return await Promise.all([tools.host.pending({ id: 1 }), tools.host.pending({ id: 2 })])`,
|
|
{ trace, limits: { timeoutMs: 100 } },
|
|
)
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.error.kind).toBe("TimeoutExceeded")
|
|
expect(trace.interrupted).toBe(2)
|
|
})
|
|
|
|
test("a non-settling race loser cannot hold the execution to the timeout", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(`return await Promise.race(["winner", tools.host.pending({ id: 1 })])`, {
|
|
trace,
|
|
limits: { timeoutMs: 100 },
|
|
})
|
|
// Completion interrupts the observed loser immediately; the race result survives.
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("winner")
|
|
expect(result.warnings).toBeUndefined()
|
|
expect(trace.starts).toEqual([1])
|
|
expect(trace.completed).toBe(0)
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("a timeout during completion cleanup keeps the computed value and warns", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
tools.host.stubborn({ cleanupMs: 400 })
|
|
return "done"
|
|
`,
|
|
{ trace, limits: { timeoutMs: 100 } },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toStrictEqual([
|
|
{
|
|
kind: "TimeoutExceeded",
|
|
message:
|
|
"The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
|
|
},
|
|
])
|
|
expect(trace.interrupted).toBe(1)
|
|
expect(trace.completed).toBe(0)
|
|
})
|
|
|
|
test("a timeout during completion cleanup reports the timeout warning before settled rejections", async () => {
|
|
const result = await run(
|
|
`
|
|
tools.host.fail({})
|
|
tools.host.stubborn({ cleanupMs: 400 })
|
|
return "done"
|
|
`,
|
|
{ limits: { timeoutMs: 100 } },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toStrictEqual([
|
|
{
|
|
kind: "TimeoutExceeded",
|
|
message:
|
|
"The program returned, but background work was still running at the 100ms timeout and was interrupted. Await all started promises.",
|
|
},
|
|
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("promise chaining", () => {
|
|
test("then transforms tool results and adopts returned promises across a chain", async () => {
|
|
expect(
|
|
await value(`
|
|
return await tools.host
|
|
.echo({ id: 2 })
|
|
.then((id) => tools.host.echo({ id: id + 1 }))
|
|
.then((id) => id * 10)
|
|
`),
|
|
).toBe(30)
|
|
})
|
|
|
|
test("handlers are deferred and run in attach order", async () => {
|
|
expect(
|
|
await value(`
|
|
const order = []
|
|
const promise = Promise.resolve(1)
|
|
promise.then(() => order.push("h1"))
|
|
promise.then(() => order.push("h2"))
|
|
order.push("sync")
|
|
await promise
|
|
return order
|
|
`),
|
|
).toEqual(["sync", "h1", "h2"])
|
|
})
|
|
|
|
test("catch recovers a tool failure and preserves fulfillment", async () => {
|
|
expect(
|
|
await value(`
|
|
return [
|
|
await tools.host.fail({}).catch((error) => error.message),
|
|
await tools.host.echo({ id: 4 }).catch(() => "unused"),
|
|
]
|
|
`),
|
|
).toEqual(["Lookup refused", 4])
|
|
})
|
|
|
|
test("finally observes settlement without changing the value", async () => {
|
|
expect(
|
|
await value(`
|
|
const events = []
|
|
const result = await tools.host.echo({ id: 5 }).finally(() => events.push("cleanup"))
|
|
return [result, events]
|
|
`),
|
|
).toEqual([5, ["cleanup"]])
|
|
})
|
|
|
|
test("a settled, un-awaited rejected chain tail warns exactly once", async () => {
|
|
const result = await run(`
|
|
Promise.reject(new Error("boom")).then((value) => value)
|
|
await Promise.resolve()
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
// The source rejection belongs to the chain (no warning); only the derived tail warns.
|
|
expect(result.warnings).toStrictEqual([
|
|
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
|
|
])
|
|
})
|
|
|
|
test("a catch handler silences the chain's rejection warning", async () => {
|
|
const result = await run(`
|
|
Promise.reject(new Error("boom")).catch(() => "handled")
|
|
await Promise.resolve()
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("unsupported callable handlers fail loudly with a wrap hint", async () => {
|
|
const diagnostic = await error(`return await tools.host.echo({ id: 1 }).then(tools.host.completed)`)
|
|
expect(diagnostic.message).toContain("Promise.prototype.then cannot use this callable as a handler")
|
|
expect(diagnostic.message).toContain("wrap it in an arrow function")
|
|
})
|
|
|
|
test("chaining methods are opaque references until called", async () => {
|
|
expect(await value(`return typeof tools.host.echo({ id: 1 }).then`)).toBe("function")
|
|
})
|
|
})
|
|
|
|
describe("combinator settlement timing", () => {
|
|
test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => {
|
|
// The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning
|
|
// program abandons it while still pending: interrupted like any pending work, so no
|
|
// rejection warning survives - the member itself was observed by the combinator.
|
|
const result = await run(`
|
|
Promise.all([Promise.reject(new Error("boom"))])
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("a combinator settles one reaction turn after its members, as in V8", async () => {
|
|
// Regression for the race winner flip: Promise.all's settlement burns a reaction turn,
|
|
// so a plain resolved value entered in the same race wins, and a fail-fast aggregate
|
|
// cannot beat it into rejection.
|
|
expect(
|
|
await value(`
|
|
const pending = tools.host.pending({ id: 9 })
|
|
const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
|
|
try {
|
|
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
|
|
return [winner, "fulfilled", raced]
|
|
} catch (reason) {
|
|
return [winner, "rejected", reason]
|
|
}
|
|
`),
|
|
).toEqual([2, "fulfilled", "ok"])
|
|
})
|
|
})
|
|
|
|
describe("unsupported promise surface", () => {
|
|
test("other property reads on a promise hint at the missing await", async () => {
|
|
const diagnostic = await error(`return tools.host.echo({ id: 1 }).value`)
|
|
expect(diagnostic.kind).toBe("InvalidDataValue")
|
|
expect(diagnostic.message).toContain("un-awaited Promise")
|
|
expect(diagnostic.message).toContain("await it first")
|
|
})
|
|
|
|
test("unknown Promise statics are not functions", async () => {
|
|
const diagnostic = await error(`return await Promise.settle(() => 1)`)
|
|
expect(diagnostic.message).toContain("Promise.settle is not a function")
|
|
})
|
|
})
|
|
|
|
describe("Promise.try", () => {
|
|
test("runs the function now with its arguments; a throw rejects, a return fulfils, a promise or thenable is adopted", async () => {
|
|
expect(
|
|
await value(`
|
|
const log = []
|
|
const sum = Promise.try((a, b) => { log.push("ran"); return a + b }, 1, 2)
|
|
log.push("after")
|
|
let caught
|
|
try { await Promise.try(() => { throw new RangeError("boom") }) } catch (e) { caught = e.constructor.name }
|
|
return [log, sum instanceof Promise, await sum, caught, await Promise.try(async () => 5), await Promise.try(() => ({ then: (r) => r("thenable") }))]
|
|
`),
|
|
).toEqual([["ran", "after"], true, 3, "RangeError", 5, "thenable"])
|
|
expect((await error(`return Promise.try(5)`)).message).toContain(
|
|
"Promise.try expects a function, received a number.",
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("Promise.withResolvers", () => {
|
|
test("returns a pending promise with resolvers that settle it once and adopt thenables", async () => {
|
|
expect(
|
|
await value(`
|
|
const first = Promise.withResolvers()
|
|
first.resolve(Promise.resolve("adopted"))
|
|
first.resolve("ignored")
|
|
const second = Promise.withResolvers()
|
|
second.reject(new Error("no"))
|
|
let caught
|
|
try { await second.promise } catch (e) { caught = e.message }
|
|
return [Object.keys(first), first.promise instanceof Promise, await first.promise, caught]
|
|
`),
|
|
).toEqual([["promise", "resolve", "reject"], true, "adopted", "no"])
|
|
})
|
|
})
|
|
|
|
describe("Promise.any", () => {
|
|
test("first tool success wins; failing and losing calls are handled silently", async () => {
|
|
const trace = makeTrace()
|
|
const result = await run(
|
|
`
|
|
const winner = await Promise.any([
|
|
tools.host.fail({}),
|
|
tools.host.echo({ id: 1 }),
|
|
tools.host.pending({ id: 2 }),
|
|
])
|
|
return winner
|
|
`,
|
|
{ trace },
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe(1)
|
|
// The slow loser stays execution-owned and is interrupted at completion; the tool
|
|
// failure was observed by the aggregate, so no rejection warning survives.
|
|
expect(result.warnings).toBeUndefined()
|
|
expect(trace.interrupted).toBe(1)
|
|
})
|
|
|
|
test("all members failing rejects with catch-normalized reasons in input order", async () => {
|
|
expect(
|
|
await value(`
|
|
try {
|
|
await Promise.any([tools.host.fail({}), Promise.reject("plain")])
|
|
return "fulfilled"
|
|
} catch (error) {
|
|
return [error.name, error.errors.map((reason) => reason.message ?? reason)]
|
|
}
|
|
`),
|
|
).toEqual(["AggregateError", ["Lookup refused", "plain"]])
|
|
})
|
|
|
|
test("settles one reaction turn after its deciding member, as in V8", async () => {
|
|
expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2)
|
|
})
|
|
|
|
test("a tie is decided by settlement order, not input order", async () => {
|
|
// Handlers run in attach order, so `first` settles before `second` and wins
|
|
// despite its later input position - as in real JS.
|
|
expect(
|
|
await value(`
|
|
const first = Promise.resolve().then(() => "one")
|
|
const second = Promise.resolve().then(() => "two")
|
|
return await Promise.any([second, first])
|
|
`),
|
|
).toBe("one")
|
|
})
|
|
|
|
test("an abandoned rejecting aggregate is interrupted silently at the return", async () => {
|
|
const result = await run(`
|
|
Promise.any([Promise.reject(new Error("boom"))])
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("promise construction", () => {
|
|
test("a deferred gate coordinates tool results across async functions", async () => {
|
|
expect(
|
|
await value(`
|
|
let openGate
|
|
const gate = new Promise((resolve) => { openGate = resolve })
|
|
const worker = (async () => {
|
|
const id = await gate
|
|
return id * 2
|
|
})()
|
|
openGate(await tools.host.echo({ id: 21 }))
|
|
return await worker
|
|
`),
|
|
).toBe(42)
|
|
})
|
|
|
|
test("the .then(resolve) bridge settles a constructed promise", async () => {
|
|
expect(
|
|
await value(`
|
|
const bridged = new Promise((resolve, reject) => {
|
|
tools.host.echo({ id: 7 }).then(resolve, reject)
|
|
})
|
|
return await bridged
|
|
`),
|
|
).toBe(7)
|
|
})
|
|
|
|
test("constructed promises participate in combinators", async () => {
|
|
expect(
|
|
await value(`
|
|
let settle
|
|
const manual = new Promise((resolve) => { settle = resolve })
|
|
const race = Promise.race([manual, tools.host.pending({ id: 3 })])
|
|
const all = Promise.all([manual, "plain"])
|
|
const any = Promise.any([manual, new Promise(() => {})])
|
|
settle("manual")
|
|
return [await race, await all, await any]
|
|
`),
|
|
).toEqual(["manual", ["manual", "plain"], "manual"])
|
|
})
|
|
|
|
test("resolving with a pending promise adopts its later settlement", async () => {
|
|
expect(
|
|
await value(`
|
|
let innerResolve, innerReject
|
|
const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve })))
|
|
const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject })))
|
|
innerResolve("later")
|
|
innerReject("bad")
|
|
try {
|
|
return [await adopted, await adoptedRejection]
|
|
} catch (reason) {
|
|
return [await adopted, reason]
|
|
}
|
|
`),
|
|
).toEqual(["later", "bad"])
|
|
})
|
|
|
|
test("an async executor's post-await resolve settles the promise", async () => {
|
|
expect(
|
|
await value(`
|
|
const result = new Promise(async (resolve) => {
|
|
const id = await tools.host.echo({ id: 5 })
|
|
resolve(id * 2)
|
|
})
|
|
return await result
|
|
`),
|
|
).toBe(10)
|
|
})
|
|
|
|
test("a never-settled promise is abandoned silently at the return", async () => {
|
|
const result = await run(`
|
|
const forever = new Promise(() => {})
|
|
forever.then(() => {})
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toBeUndefined()
|
|
})
|
|
|
|
test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => {
|
|
const result = await run(`
|
|
new Promise((_, reject) => reject(new Error("dropped")))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
return "done"
|
|
`)
|
|
expect(result.ok).toBe(true)
|
|
if (!result.ok) return
|
|
expect(result.value).toBe("done")
|
|
expect(result.warnings).toHaveLength(1)
|
|
expect(result.warnings?.[0].message).toContain("Unhandled rejection")
|
|
expect(result.warnings?.[0].message).toContain("dropped")
|
|
})
|
|
|
|
test("resolver functions vanish at the data boundary like JSON.stringify", async () => {
|
|
expect(
|
|
await value(`
|
|
let escaped
|
|
new Promise((resolve) => { escaped = resolve })
|
|
return { escaped, kind: typeof escaped }
|
|
`),
|
|
).toEqual({ kind: "function" })
|
|
})
|
|
})
|
|
|
|
describe("jobs run to completion", () => {
|
|
// The runner pauses a long job to let the host run; the paused job still finishes before any other job starts.
|
|
test("updates to shared state between awaits are not lost", async () => {
|
|
expect(
|
|
await value(`
|
|
let total = 0
|
|
const work = async () => {
|
|
await null
|
|
for (let i = 0; i < 1000; i++) total = total + 1
|
|
}
|
|
await Promise.all([work(), work()])
|
|
return total
|
|
`),
|
|
).toBe(2000)
|
|
})
|
|
|
|
test("an async function runs to its first await before its caller continues", async () => {
|
|
expect(
|
|
await value(`
|
|
let count = 0
|
|
const work = async () => {
|
|
for (let i = 0; i < 1000; i++) count = count + 1
|
|
await null
|
|
}
|
|
work()
|
|
return count
|
|
`),
|
|
).toBe(1000)
|
|
})
|
|
|
|
test("a combinator's reaction runs after the continuations a preempted job queued first", async () => {
|
|
for (const op of ["race", "any", "all", "allSettled"]) {
|
|
expect(
|
|
await value(`
|
|
const burn = () => { for (let i = 0; i < 400; i++) {} }
|
|
const log = []
|
|
const settled = Promise.${op}([{ then(resolve) { burn(); resolve(1) } }]).then(() => log.push("result"))
|
|
const job = (async () => { await null; burn(); log.push("b0"); await null; log.push("b1") })()
|
|
await Promise.all([settled, job])
|
|
return log
|
|
`),
|
|
).toEqual(["b0", "b1", "result"])
|
|
}
|
|
})
|
|
|
|
test("a job that never awaits still times out while other jobs wait for it", async () => {
|
|
const failure = await error(
|
|
`
|
|
const spin = async () => { await null; while (true) {} }
|
|
const quick = async () => { await null; return 1 }
|
|
return await Promise.all([spin(), quick(), quick()])
|
|
`,
|
|
{ limits: { timeoutMs: 100 } },
|
|
)
|
|
expect(failure.kind).toBe("TimeoutExceeded")
|
|
})
|
|
})
|
|
|
|
// Each case logs from several jobs; the program then drains the queue. Expected orders are what Node 25 (V8) logs for
|
|
// the same code.
|
|
const v8Order: ReadonlyArray<readonly [name: string, code: string, expected: Array<string | number>]> = [
|
|
[
|
|
"then vs await",
|
|
`Promise.resolve().then(() => log.push("then")); (async () => { await null; log.push("await") })(); log.push("sync")`,
|
|
["sync", "then", "await"],
|
|
],
|
|
[
|
|
"adopt promise from async fn",
|
|
`async function f() { return Promise.resolve(1) } f().then(() => log.push("f")); Promise.resolve().then(() => log.push("a")).then(() => log.push("b")).then(() => log.push("c")).then(() => log.push("d"))`,
|
|
["a", "b", "f", "c", "d"],
|
|
],
|
|
[
|
|
"resolve with promise",
|
|
`new Promise((r) => r(Promise.resolve())).then(() => log.push("adopted")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, 2, "adopted", 3],
|
|
],
|
|
[
|
|
"await thenable",
|
|
`(async () => { await { then(r) { log.push("then called"); r(1) } }; log.push("after thenable") })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
["then called", 1, "after thenable", 2, 3],
|
|
],
|
|
[
|
|
"finally",
|
|
`Promise.resolve(1).finally(() => log.push("fin")).then(() => log.push("after finally")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4))`,
|
|
["fin", 1, 2, 3, "after finally", 4],
|
|
],
|
|
[
|
|
"finally rejected",
|
|
`Promise.reject(1).finally(() => log.push("fin")).catch(() => log.push("caught")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4))`,
|
|
["fin", 1, 2, 3, "caught", 4],
|
|
],
|
|
[
|
|
"Promise.all",
|
|
`Promise.all([1, Promise.resolve(2)]).then(() => log.push("all")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "all", 2, 3],
|
|
],
|
|
[
|
|
"Promise.all empty",
|
|
`Promise.all([]).then(() => log.push("all")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2))`,
|
|
["all", 1, 2],
|
|
],
|
|
[
|
|
"Promise.race",
|
|
`Promise.race([Promise.resolve(1), 2]).then((v) => log.push("race " + v)); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "race 1", 2, 3],
|
|
],
|
|
[
|
|
"Promise.allSettled",
|
|
`Promise.allSettled([Promise.reject(1), 2]).then(() => log.push("settled")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "settled", 2, 3],
|
|
],
|
|
[
|
|
"Promise.any",
|
|
`Promise.any([Promise.reject(1), Promise.resolve(2)]).then(() => log.push("any")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "any", 2, 3],
|
|
],
|
|
[
|
|
"async generator",
|
|
`async function* g() { log.push("start"); yield 1; log.push("mid"); yield 2; log.push("end") } const it = g(); it.next().then((r) => log.push("n1 " + r.value)); it.next().then((r) => log.push("n2 " + r.value)); it.next().then((r) => log.push("n3 " + r.done)); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4))`,
|
|
["start", "mid", 1, "n1 1", "end", 2, "n2 2", "n3 true", 3, 4],
|
|
],
|
|
[
|
|
"async generator return",
|
|
`async function* g() { try { yield 1 } finally { log.push("cleanup") } } const it = g(); it.next().then(() => it.return(5)).then((r) => log.push("ret " + r.value)); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4)).then(() => log.push(5))`,
|
|
[1, 2, "cleanup", 3, 4, "ret 5", 5],
|
|
],
|
|
[
|
|
"for await sync iterable",
|
|
`(async () => { for await (const x of [1, Promise.resolve(2), 3]) log.push("x" + x) })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4)).then(() => log.push(5)).then(() => log.push(6))`,
|
|
[1, "x1", 2, 3, "x2", 4, 5, "x3", 6],
|
|
],
|
|
[
|
|
"for await async gen",
|
|
`async function* g() { yield 1; yield 2 } (async () => { for await (const x of g()) log.push("x" + x) })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4)).then(() => log.push(5)).then(() => log.push(6))`,
|
|
[1, "x1", 2, 3, "x2", 4, 5, 6],
|
|
],
|
|
[
|
|
"nested async",
|
|
`async function a() { log.push("a1"); await b(); log.push("a2") } async function b() { log.push("b1"); await null; log.push("b2") } a(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
["a1", "b1", "b2", 1, "a2", 2, 3],
|
|
],
|
|
[
|
|
"await rejected",
|
|
`(async () => { try { await Promise.reject(1) } catch { log.push("caught") } })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2))`,
|
|
["caught", 1, 2],
|
|
],
|
|
[
|
|
"then handler returns promise",
|
|
`Promise.resolve().then(() => Promise.resolve("x")).then((v) => log.push(v)); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4))`,
|
|
[1, 2, 3, "x", 4],
|
|
],
|
|
[
|
|
"withResolvers",
|
|
`const { promise, resolve } = Promise.withResolvers(); promise.then(() => log.push("resolved")); Promise.resolve().then(() => { log.push(1); resolve() }).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "resolved", 2, 3],
|
|
],
|
|
[
|
|
"sync generator in async",
|
|
`function* g() { yield 1; yield 2 } (async () => { for (const x of g()) { await null; log.push("g" + x) } })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
["g1", 1, "g2", 2, 3],
|
|
],
|
|
[
|
|
"async return await",
|
|
`async function f() { return await Promise.resolve(1) } f().then(() => log.push("f")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "f", 2, 3],
|
|
],
|
|
[
|
|
"yield* sync in async gen",
|
|
`async function* g() { yield* [1, Promise.resolve(2)] } (async () => { for await (const x of g()) log.push("x" + x) })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4)).then(() => log.push(5)).then(() => log.push(6)).then(() => log.push(7))`,
|
|
[1, 2, "x1", 3, 4, 5, "x2", 6, 7],
|
|
],
|
|
[
|
|
"break in for await sync",
|
|
`(async () => { for await (const x of [1, 2]) { log.push("x" + x); break } log.push("after") })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4))`,
|
|
[1, "x1", 2, "after", 3, 4],
|
|
],
|
|
[
|
|
"for await rejected value",
|
|
`(async () => { try { for await (const x of [Promise.reject("r")]) log.push(x) } catch (e) { log.push("caught " + e) } })(); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4))`,
|
|
[1, "caught r", 2, 3, 4],
|
|
],
|
|
[
|
|
"unhandled then later handled",
|
|
`const p = Promise.reject(1); Promise.resolve().then(() => p.catch(() => log.push("late catch"))); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))`,
|
|
[1, "late catch", 2, 3],
|
|
],
|
|
[
|
|
"generator return in finally",
|
|
`function* g() { try { yield 1 } finally { yield 2 } } const it = g(); log.push(JSON.stringify(it.next())); log.push(JSON.stringify(it.return(9))); log.push(JSON.stringify(it.next()))`,
|
|
['{"value":1,"done":false}', '{"value":2,"done":false}', '{"value":9,"done":true}'],
|
|
],
|
|
[
|
|
"async gen await in finally",
|
|
`async function* g() { try { yield 1 } finally { await null; log.push("fin") } } const it = g(); it.next().then(() => it.return(3)).then((r) => log.push("r" + r.value)); Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3)).then(() => log.push(4)).then(() => log.push(5))`,
|
|
[1, 2, 3, "fin", 4, 5, "r3"],
|
|
],
|
|
[
|
|
"thenable job then throws",
|
|
`Promise.resolve({ then() { throw new Error("boom") } }).catch((e) => log.push(e.message)); Promise.resolve().then(() => log.push(1)).then(() => log.push(2))`,
|
|
[1, "boom", 2],
|
|
],
|
|
[
|
|
"Promise.try",
|
|
`Promise.try(() => log.push("ran")).then(() => log.push("try")); Promise.resolve().then(() => log.push(1)).then(() => log.push(2))`,
|
|
["ran", "try", 1, 2],
|
|
],
|
|
]
|
|
|
|
describe("job order matches V8", () => {
|
|
test.each(v8Order)("%s", async (_, code, expected) => {
|
|
expect(await value(`const log = []; ${code}; for (let i = 0; i < 20; i++) await null; return log`)).toEqual(
|
|
expected,
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("a program that can never finish", () => {
|
|
const stalled =
|
|
"Error: The program is waiting on a promise that can never settle: nothing else is running, so nothing can resolve it. Code Mode has no timers or sleep, so don't wait or poll inside a program."
|
|
|
|
// Each program stalls at the await on line `line`, column `column`; an outer await only waits on that one.
|
|
test.each([
|
|
["the program's own await", "await new Promise((r) => r)", 1, 1],
|
|
[
|
|
"an async function whose result the program returns",
|
|
["async function wait() {", " await new Promise(() => {})", "}", "return wait()"].join("\n"),
|
|
2,
|
|
3,
|
|
],
|
|
[
|
|
"an async function the program awaits",
|
|
["async function wait() {", " await new Promise(() => {})", "}", "await wait()"].join("\n"),
|
|
2,
|
|
3,
|
|
],
|
|
[
|
|
"a later await in an async body the program awaits",
|
|
[
|
|
"const { promise } = Promise.withResolvers()",
|
|
"const p = (async () => { await null; await promise })()",
|
|
"await p",
|
|
].join("\n"),
|
|
2,
|
|
38,
|
|
],
|
|
])("fails at once, without a timeout, at the stuck await in %s", async (_, code, line, column) => {
|
|
expect(await error(code)).toEqual({
|
|
kind: "ExecutionFailure",
|
|
message: `${stalled} (line ${line}, col ${column})`,
|
|
location: { line, column },
|
|
})
|
|
})
|
|
|
|
test("fails when the unsettleable promise is deep inside other async work", async () => {
|
|
const failure = await error(`
|
|
const never = async () => { await null; await new Promise(() => {}) }
|
|
return await Promise.all([never(), Promise.resolve(1)])
|
|
`)
|
|
expect(failure.message).toStartWith(stalled)
|
|
})
|
|
|
|
test("cannot be caught by the program", async () => {
|
|
expect(await error(`try { await new Promise(() => {}) } catch { return "caught" }`)).toEqual({
|
|
kind: "ExecutionFailure",
|
|
message: `${stalled} (line 1, col 7)`,
|
|
location: { line: 1, column: 7 },
|
|
})
|
|
})
|
|
|
|
test("is not stalled while a tool call that settles a hand-made promise is still running", async () => {
|
|
expect(
|
|
await value(`
|
|
const { promise, resolve } = Promise.withResolvers()
|
|
tools.host.gated({ id: 1 }).then(resolve)
|
|
tools.host.open({ id: 1 })
|
|
return await promise
|
|
`),
|
|
).toBe(1)
|
|
})
|
|
|
|
test("is not stalled while a tool call it waits on is still running", async () => {
|
|
expect(
|
|
await value(`
|
|
const call = tools.host.gated({ id: 1 })
|
|
await tools.host.open({ id: 1 })
|
|
return await call
|
|
`),
|
|
).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe("host results", () => {
|
|
test("a tool result settles after the jobs already queued, like I/O", async () => {
|
|
expect(
|
|
await value(`
|
|
const log = []
|
|
const call = tools.host.echo({ id: 1 }).then(() => log.push("tool"))
|
|
Promise.resolve().then(() => log.push(1)).then(() => log.push(2)).then(() => log.push(3))
|
|
await call
|
|
return log
|
|
`),
|
|
).toEqual([1, 2, 3, "tool"])
|
|
})
|
|
|
|
test("an awaited interruption passes catch and finally and interrupts the program", async () => {
|
|
const exit = await Effect.runPromiseExit(
|
|
CodeMode.execute({
|
|
tools: { host: { interrupt: interruptedTool } },
|
|
code: `
|
|
const inner = async () => {
|
|
try { await tools.host.interrupt({}) } catch { return "caught" } finally { return "finally" }
|
|
}
|
|
return await inner().catch(() => "handled")
|
|
`,
|
|
}),
|
|
)
|
|
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
|
})
|
|
})
|