feat(codemode): interrupt all pending work at program return

Replace the two-class completion lifecycle with one rule: once the
program returns, every promise still running - race losers, fail-fast
stragglers, and fire-and-forget calls alike - is interrupted rather
than awaited. Work whose completion matters must be awaited by the
program.

Waiting for un-awaited work before interrupting observed leftovers
could hold the execution open or deadlock it outright: a fire-and-
forget chain awaiting a race loser, or queued work needing tool-call
permits the losers occupied, blocked a drain that only the pending
interruption could unblock. Rejections that settled un-awaited before
the return still surface as Success.warnings diagnostics.

Give warnings their own output byte budget equal to maxOutputBytes so
a budget-consuming value can never starve runtime diagnostics, and
state the lifetime rule in the model-facing instructions. Port ten
curated Test262 combinator cases (duplicate members, already-settled
inputs, ordering, resolve/reject flattening) and cover the timeout-
during-cleanup path with a never-settling slow-cleanup harness tool.
This commit is contained in:
Aiden Cline 2026-07-09 23:22:28 -05:00
parent d785803dd6
commit 4bc90593df
9 changed files with 328 additions and 76 deletions

View file

@ -145,7 +145,7 @@ interface Failure {
}
```
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises the program started but never observed, or background work interrupted by the timeout after the program returned. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed before the program returned without ever being awaited, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
### Tool-call hooks
@ -262,7 +262,7 @@ The limits are exactly three knobs:
| ---------------- | -------------------: | -------------------------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained payload: result value, warnings, and logs. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
@ -280,11 +280,11 @@ const runtime = CodeMode.make({
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value, retained warning diagnostics, and retained log lines. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, leading warnings and logs are kept within the remaining budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. If the timeout fires after the program has already returned a valid value - while the runtime is waiting on un-awaited background work - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
@ -301,9 +301,10 @@ Failures are data:
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`; as a warning, background work was interrupted after the program returned. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning-only marker: preceding warnings were omitted by `maxOutputBytes`. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:

View file

@ -67,18 +67,20 @@ Every sandbox promise starts eagerly on a run-once fiber owned by the whole Code
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 running. At normal completion CodeMode drains unobserved promises to empty (fire-and-forget
work finishes, and its failures become `Success.warnings` diagnostics), then interrupts whatever remains active - all
of it observed (race losers, fail-fast `Promise.all` stragglers), and since the program has returned, no future await
can exist. An unobserved promise that itself awaits an observed loser transitively keeps that loser alive through the
draining phase; interruption applies only to work nothing unobserved still depends on. A fatal program failure or host interruption closes the execution promise scope and interrupts its active
fibers instead. A timeout does the same, except that a value the program already returned is preserved alongside a
`TimeoutExceeded` warning rather than discarded. At most eight tool calls execute concurrently.
wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers,
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
discarded. 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
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
fixed truncation notices and host-added framing are intentionally outside the budget.
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
host-added framing are intentionally outside the budgets.
### Data, files, and failures

View file

@ -116,9 +116,9 @@ ultimate source of truth.
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
- [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed
combinator batches overlap as in normal JavaScript.
- [x] Un-awaited (unobserved) work is drained before successful completion; unhandled failures become
`Success.warnings` diagnostics. Observed leftovers (race losers, fail-fast `Promise.all` stragglers) are
interrupted at completion since no future await can exist.
- [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is
interrupted when the program returns; rejections that settled un-awaited become `Success.warnings`
diagnostics.
- [x] `try`/`catch` can handle awaited tool and promise failures.
- [ ] `Promise.any`.
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.

View file

@ -93,9 +93,7 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
export const Success = Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
// Runtime-authored, non-fatal diagnostics alongside a valid value (stderr to `value`'s
// stdout): unhandled rejections, background work interrupted by the timeout. Program-
// authored console output stays in `logs`.
// Runtime-authored non-fatal diagnostics; program console output stays in `logs`.
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),

View file

@ -611,8 +611,8 @@ 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.
// Promise work lives until the program returns, while observation only controls whether a
// settled rejection is reported. Neither extends execution: completion interrupts all work.
class PromiseRuntime<R> {
private readonly active = new Set<SandboxPromise>()
private readonly ids = new WeakMap<SandboxPromise, number>()
@ -664,29 +664,23 @@ class PromiseRuntime<R> {
return Fiber.await(promise.fiber)
}
// Unobserved rejections that already settled, in creation order. Consumed by normal-
// completion draining and by the timeout path when the program value already exists.
// Unobserved rejections that already settled, in creation order.
diagnostics(): Array<Diagnostic> {
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
}
// Normal-completion lifecycle. Unobserved promises are fire-and-forget work nothing ever
// took responsibility for: wait for them (their side effects were the point) and report
// their failures. Once none remain, whatever is still active is necessarily observed - race
// losers and fail-fast Promise.all stragglers the program moved past - and since the program
// has returned, no future await can exist: interrupt instead of waiting. Loops until active
// is literally empty because a straggler can create new promises before its interrupt lands.
// Normal-completion lifecycle. Once the program returns, no future await can exist, so
// everything still running - race losers, fail-fast stragglers, and fire-and-forget calls
// alike - is interrupted rather than awaited: work whose completion matters must be awaited
// by the program. Waiting for any class of leftover instead would let it hold the execution
// open (or deadlock it, when queued work needs tool-call permits the leftovers occupy).
// interruptAll signals every fiber synchronously before awaiting termination, so no
// straggler can spawn new work between interrupts; the loop re-checks as a backstop
// because a straggler can create promises before its interrupt lands.
drain(): Effect.Effect<Array<Diagnostic>> {
const self = this
return Effect.gen(function* () {
while (self.active.size > 0) {
const unobserved = [...self.active].filter((promise) => !self.observed.has(promise))
if (unobserved.length > 0) {
for (const promise of unobserved) yield* Fiber.await(promise.fiber)
continue
}
// Signals every leftover synchronously before awaiting termination, so no straggler
// can spawn new work between interrupts; the loop re-checks as a backstop.
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
}
return self.diagnostics()
@ -2242,7 +2236,7 @@ class Interpreter<R> {
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
// promise already fulfilled with the value. Pre-settled values still fork a scope-owned
// fiber so every promise shares one lifecycle (an abandoned reject is reported, teardown
// is uniform); an inert fast path is possible if this ever shows up in profiles.
// is uniform).
const value = args[0]
return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value))
}
@ -3513,7 +3507,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
// Set once the program body returned and its value crossed the data boundary, so a timeout
// firing during the final drain reports "completed with interrupted background work"
// instead of discarding the computed value as a plain timeout.
let drained: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
const base = Effect.acquireUseRelease(
Scope.make("parallel"),
@ -3526,7 +3520,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
// Validate the result before draining so an invalid value is a fatal completion that
// closes the promise scope instead of waiting for unrelated work.
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
drained = { value: result, promises }
returned = { value: result, promises }
const warnings = yield* promises.drain()
return {
ok: true,
@ -3547,7 +3541,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
duration: timeoutMs,
orElse: () =>
Effect.sync(() => {
if (drained === undefined) {
if (returned === undefined) {
return {
ok: false,
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
@ -3558,13 +3552,13 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
// The timeout warning leads so byte-budget truncation cuts it last.
return {
ok: true,
value: drained.value,
value: returned.value,
warnings: [
{
kind: "TimeoutExceeded",
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await or explicitly settle all started promises.`,
},
...drained.promises.diagnostics(),
...returned.promises.diagnostics(),
],
...logged(),
toolCalls: tools.calls,
@ -3603,11 +3597,12 @@ const utf8Truncate = (value: string, maxBytes: number): string => {
}
/**
* Bounds retained payload bytes (serialized result value, warning diagnostics, and logs) to
* `maxOutputBytes`. Fixed truncation notices are added outside that payload budget, as is any
* framing added when a host renders the structured result. Truncation never fails the execution;
* `truncated: true` marks affected results. Only runs when the host set `maxOutputBytes` - with
* the limit absent, output passes through unbounded.
* Bounds retained program payload bytes (serialized result value and logs) to `maxOutputBytes`.
* Warning diagnostics are bounded by a separate budget of the same size so a large value can
* never starve runtime-authored diagnostics. Fixed truncation notices are added outside those
* budgets, as is any framing added when a host renders the structured result. Truncation never
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
*/
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
let truncated = false
@ -3629,17 +3624,15 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
const warnings = result.ok ? (result.warnings ?? []) : []
const keptWarnings: Array<Diagnostic> = []
const warningBudget = Math.max(0, maxOutputBytes - valueBytes)
let warningBytes = 0
for (const warning of warnings) {
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
if (warningBytes + bytes > warningBudget) break
if (warningBytes + bytes > maxOutputBytes) break
warningBytes += bytes
keptWarnings.push(warning)
}
if (keptWarnings.length < warnings.length) {
truncated = true
// In-band marker, like the value and log markers; outside the payload budget.
keptWarnings.push({
kind: "Truncated",
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
@ -3648,7 +3641,7 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
const logs = result.logs ?? []
const kept: Array<string> = []
const logBudget = Math.max(0, maxOutputBytes - valueBytes - warningBytes)
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
let logBytes = 0
for (const line of logs) {
const lineBytes = utf8ByteLength(line) + 1

View file

@ -602,6 +602,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- Execution ends when the program returns; calls still running (including race losers) are interrupted, so await every call whose completion matters.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []

View file

@ -245,6 +245,195 @@ describe("Test262 Promise statics", () => {
`)
expect(result).toEqual([true, true, true, true, true, true, true, true])
})
test("Promise.all resolves duplicate members into every slot", async () => {
// Sources:
// test/built-ins/Promise/all/invoke-resolve-on-promises-every-iteration-of-promise.js
// test/built-ins/Promise/all/invoke-resolve-on-values-every-iteration-of-promise.js
// (adapted: CodeMode has no observable Promise.resolve hook, so per-iteration
// handling of a repeated member is asserted through the resolved slots)
expect(
await value(`
const settled = Promise.resolve(3)
const computed = (async () => "computed")()
return [
await Promise.all([settled, settled, settled]),
await Promise.all([computed, "plain", computed]),
]
`),
).toEqual([
[3, 3, 3],
["computed", "plain", "computed"],
])
})
test("Promise.allSettled records duplicate members independently", async () => {
// Source: test/built-ins/Promise/allSettled/invoke-resolve-on-promises-every-iteration-of-promise.js
// (adapted: per-iteration handling of a repeated member is asserted through the
// outcome records instead of a Promise.resolve hook)
expect(
await value(`
const good = Promise.resolve(1)
const bad = Promise.reject(2)
return await Promise.allSettled([good, bad, good, bad])
`),
).toEqual([
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: 2 },
])
})
test("combinators adopt members that settled before the call", async () => {
// Sources:
// test/built-ins/Promise/all/reject-immed.js
// test/built-ins/Promise/allSettled/reject-immed.js
// test/built-ins/Promise/race/reject-immed.js
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
// and were even observed, before the combinator call)
expect(
await value(`
const fulfilled = Promise.resolve("done")
const rejected = Promise.reject("failed")
try { await rejected } catch {}
const observe = async (promise) => {
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
}
return [
await observe(Promise.all([fulfilled, rejected])),
await Promise.allSettled([rejected, fulfilled]),
await observe(Promise.race([rejected, fulfilled])),
]
`),
).toEqual([
["rejected", "failed"],
[
{ status: "rejected", reason: "failed" },
{ status: "fulfilled", value: "done" },
],
["rejected", "failed"],
])
})
test("combinator results follow input order, not settlement order", async () => {
// Sources:
// test/built-ins/Promise/all/resolve-non-thenable.js
// test/built-ins/Promise/allSettled/resolved-all-mixed.js
// (adapted: members are created, and therefore settle, in reverse of input order;
// deferred settlement is not expressible without host-async work in this corpus)
expect(
await value(`
const third = Promise.resolve("c")
const failing = (async () => { throw "b" })()
try { await failing } catch {}
const second = (async () => "b")()
const first = Promise.resolve("a")
return [
await Promise.all([first, second, third]),
await Promise.allSettled([first, failing, third]),
]
`),
).toEqual([
["a", "b", "c"],
[
{ status: "fulfilled", value: "a" },
{ status: "rejected", reason: "b" },
{ status: "fulfilled", value: "c" },
],
])
})
test("Promise.race ignores a rejected loser once the first contender wins", async () => {
// Source: test/built-ins/Promise/race/reject-ignored-immed.js
// (adapted: the losing rejection comes from an async function instead of a thenable;
// the exact-equality check also asserts the loser leaves no unhandled-rejection warning)
expect(
await execute(`
const loser = (async () => { throw "lost" })()
return await Promise.race([Promise.resolve("won"), loser])
`),
).toEqual({ ok: true, value: "won", toolCalls: [] })
})
test("Promise.race([]) returns a promise whose CodeMode failure is catchable", async () => {
// Sources:
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
// divergence rather than the spec never-settles behavior)
expect(
await value(`
const empty = Promise.race([])
try {
await empty
return "settled"
} catch (error) {
return [empty instanceof Promise, error instanceof Error]
}
`),
).toEqual([true, true])
})
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
// (adapted: no executor construction, and identity is observed with Array includes
// because promises are not comparable data values in CodeMode)
expect(
await value(`
const promise = Promise.resolve({ id: 1 })
return [
[promise].includes(Promise.resolve(promise)),
[promise].includes(Promise.resolve(Promise.resolve(promise))),
(await Promise.resolve(Promise.resolve(promise))).id,
]
`),
).toEqual([true, true, 1])
})
test("Promise.resolve of a rejected promise preserves identity and reason", async () => {
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.3_T1.js
// (adapted: the source promise is already rejected instead of rejected later)
expect(
await value(`
const rejected = Promise.reject("oops")
const adopted = Promise.resolve(rejected)
const identity = [rejected].includes(adopted)
try {
await adopted
return "fulfilled"
} catch (reason) {
return [identity, reason]
}
`),
).toEqual([true, "oops"])
})
test("Promise.reject uses a promise reason without flattening it", async () => {
// Sources:
// test/built-ins/Promise/reject-via-fn-immed.js
// test/built-ins/Promise/reject-via-fn-deferred.js
// (adapted: the promise reason goes through Promise.reject instead of executor reject)
expect(
await value(`
const observe = async (reason) => {
try {
await Promise.reject(reason)
return "fulfilled"
} catch (caught) {
const identity = [reason].includes(caught)
try { return [identity, caught instanceof Promise, await caught] }
catch (inner) { return [identity, caught instanceof Promise, "rethrew " + inner] }
}
}
return [await observe(Promise.resolve(1)), await observe(Promise.reject("inner"))]
`),
).toEqual([
[true, true, 1],
[true, true, "rethrew inner"],
])
})
})
describe("Test262 async functions and await", () => {
@ -364,6 +553,27 @@ describe("Test262 async functions and await", () => {
`),
).toBe(true)
})
test("await returns non-promise operands unchanged", async () => {
// Source: test/language/expressions/await/await-non-promise.js
// (adapted: only value pass-through is asserted here; the spec tick ordering around
// await of non-promises is covered by the failing interleaving test below)
expect(
await value(`
const object = { id: 1 }
const array = [1, 2]
return [
await 1,
await "text",
await true,
(await null) === null,
(await undefined) === undefined,
(await object) === object,
(await array) === array,
]
`),
).toEqual([1, "text", true, true, true, true, true])
})
})
describe("Test262 expected Promise conformance", () => {

View file

@ -63,6 +63,25 @@ const completedTool = (trace: Trace) =>
run: () => Effect.succeed(trace.completed),
})
/** Never settles, and holds interruption cleanup for `cleanupMs` so a drain 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,
run: ({ 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 } = {},
@ -76,6 +95,7 @@ const run = (
fail: failingTool,
interrupt: interruptedTool,
completed: completedTool(trace),
stubborn: stubbornTool(trace),
},
},
code,
@ -203,18 +223,21 @@ describe("first-class promise values", () => {
expect(result.warnings).toBeUndefined()
})
test("a fire-and-forget call completes before the execution ends", async () => {
test("a fire-and-forget call is interrupted when the program returns", async () => {
const trace = makeTrace()
const result = await value(
const result = await run(
`
tools.host.sleepy({ id: 1, ms: 30 })
return "done"
`,
{ trace },
)
expect(result).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
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 () => {
@ -261,21 +284,43 @@ describe("first-class promise values", () => {
])
})
test("drains and reports promises started by an async function after an await", async () => {
const result = await run(`
const run = async () => {
await tools.host.sleepy({ id: 1 })
tools.host.fail({})
}
run()
return "done"
`)
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: Uncaught: 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.sleepy({ id: 1, ms: 60000 })
tools.host.fail({})
}
run()
return "done"
`,
{ trace },
)
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(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 () => {
@ -346,8 +391,8 @@ describe("first-class promise values", () => {
await value(
`
const launch = async () => {
tools.host.sleepy({ id: 1, ms: 40 })
Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
tools.host.sleepy({ id: 1, ms: 60000 })
Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
return "returned"
}
return await launch()
@ -355,9 +400,11 @@ describe("first-class promise values", () => {
{ 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(2)
expect(trace.interrupted).toBe(0)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(2)
})
})
@ -846,7 +893,7 @@ describe("timeout interruption of forked calls", () => {
const trace = makeTrace()
const result = await run(
`
tools.host.sleepy({ id: 1, ms: 60000 })
tools.host.stubborn({ cleanupMs: 400 })
return "done"
`,
{ trace, limits: { timeoutMs: 100 } },
@ -869,7 +916,7 @@ describe("timeout interruption of forked calls", () => {
const result = await run(
`
tools.host.fail({})
tools.host.sleepy({ id: 1, ms: 60000 })
tools.host.stubborn({ cleanupMs: 400 })
return "done"
`,
{ limits: { timeoutMs: 100 } },

View file

@ -177,7 +177,7 @@ function formatResult(result: CodeMode.Result) {
.join("\n")
.trim()
const warnings =
result.ok && result.warnings !== undefined && result.warnings.length > 0
result.ok && result.warnings && result.warnings.length > 0
? `Warnings:\n${result.warnings.map((item) => `- [${item.kind}] ${item.message}`).join("\n")}`
: undefined
const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined