Skip to content

The bounds a program runs under

Four bounds hold a program while it runs, and an operator adds two more above them. Three of the six are yours to set, one is fixed by the protocol, and the last two belong to whoever runs the server.

Bound Where you set it Who meets it
Timeout The request, or the executor The model, as a declared text
Memory limit The executor The model, as a declared text
Queue The pool options Your host, as a thrown fault
Payload Fixed by the protocol as PAYLOAD_LIMIT The model, as a declared text
Largest requestable timeout The server, as MAX_TIMEOUT_MS The model, as an ordinary timeout
Response limit The server, as RESPONSE_LIMIT_BYTES Your host, as a thrown fault

Leave them all unset and every one of them has a default. Each value is listed in the published API beside the file that declares it, and that listing is the copy to trust when a number matters to you. Three of the six reach the model as a declared text, two reach your host as a fault it can catch, and the timeout cap reaches the model as an ordinary timeout.

Every execution runs under a timeout, and a program still working when the bound lands is ended there. Set the number on the request, or on the executor for every run it serves:

await executor.execute({ code, timeout: 5_000 })

Time is measured on the host, not from inside the guest. The distinction matters because a program that never yields cannot be interrupted at an arbitrary point, and one long V8 builtin runs to completion whatever anybody wants. Such a run settles at its bound anyway, and the isolate is disposed afterwards. Either way, the declared text names the bound the execution actually ran under and tells the model what to do about it:

Execution timed out at its 5000 ms bound. Do less work per run and return the part that is already finished.

How long you wait for that text depends on what the guest was doing when the bound passed. Programs that yield get it at the bound: the entry module wins its own race against the clock and reports from inside the isolate. A program that never gives the thread back cannot report from inside, so the host closes the run from outside one grace period later. Measured on a warm pool, a long await under a 500 ms bound comes back at the bound, and a while (true) {} under the same bound comes back a whole grace period after it. Daemon operators set that grace, and in your own process it is fixed. Even that grace does not end the worker: the pool holds it a little longer while the builtin finishes and then terminates it. Nobody waits for that part, though a pool of one worker has no spare capacity until it passes.

Waiting on the caller does not spend the clock. While your dispatch or your outbound function is in flight the clock stops, because you can bound your own function and the guest cannot make you slow:

const executor = new IsolateExecutor({ timeout: 1000 })
await executor.execute({
code: 'async () => await store.slow()',
providers: [{ name: 'store', tools: ['slow'] }],
dispatch: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
return JSON.stringify({ result: 'answered after 3 s' })
},
})
// { result: 'answered after 3 s', logs: [] }, after about 3.1 s of wall time

dispatch takes three seconds there and the run still answers under a 1000 ms bound, because those three seconds belong to your function and not to the guest. An approval flow that takes ten minutes to answer does not end a run whose own work stayed inside its bound. Every other kind of work counts:

What the run does Does it spend the bound
Guest compute Yes
A guest setTimeout Yes, and the work its callback does counts even while a tool call is in flight
A tool call your dispatch serves No
A request your outbound function serves No
A request the executor performs natively Yes, because the guest chose the URL
A crypto.subtle operation the host performs Yes

countHostWaits: true puts the plain wall clock back, on the executor, the request or the wire call. That same slow tool under the same one-second bound then reports the timeout, not the answer. Dialects that measure the wall want exactly that. But a run whose clock is stopped still occupies its worker. Bound your own tool and outbound latency. An executor out of capacity turns new work away at the queue and never ends a run that is waiting.

A timeout is a promise to the caller and a bound on how long one worker is unavailable. Two numbers frame it: the slowest program you are willing to wait for, and how many workers you are willing to have occupied by programs that will not finish. Set it low and you cut off work that would have finished. Set it high and one stuck program holds a worker for that long. timeout: null lifts the bound to MAX_SUPPORTED_TIMEOUT, the largest delay a 32-bit timer accepts, or about twenty-five days. Lifting it is a real option for a batch job on a pool nobody else shares, but a bad one anywhere a caller can reach. So an operator can cap it on the server.

Every execution carries a memory limit in megabytes, and the engine enforces it on the isolate’s heap. No request field moves it. Set it on the executor, and every isolate that executor starts gets the same number:

const executor = new IsolateExecutor({ memoryLimit: 128 })

Every allocation path a program can take inside the isolate is metered. The meter holds only because the three paths V8 keeps outside it are shut: WebAssembly is gone from the global, and a buffer that grows after it is created throws, for an ArrayBuffer and a SharedArrayBuffer alike. Each of the three closures has its reason in Runtime deviations. Two endings exist. Which one a program meets depends on how it asks. Programs that fill the heap in steps are ended by the engine, and the run reports the declared text:

const executor = new IsolateExecutor({ memoryLimit: 32 })
await executor.execute({
code: `async () => { const held = []; const s = 'x'.repeat(1024); for (let i = 0; i < 1e9; i++) { held.push(s + i) } }`,
})
// error: 'Execution ran out of memory at its 32 MB limit. Work through the data in smaller
// pieces and keep only what you return.'

That text names 32 MB, the limit this executor declared. Programs that ask for a single buffer the limit cannot grant are refused at the allocation with an error they can catch, so a careful one recovers:

await executor.execute({
code: `async () => {
try {
new Uint8Array(1024 * 1024 * 1024)
} catch (failure) {
return { name: failure.name, message: failure.message }
}
}`,
})
// { result: { name: 'RangeError', message: 'Array buffer allocation failed' }, logs: [] }

Array buffer allocation failed comes from V8 and not from this executor, so it is not contract and a host should not match on it. Programs that catch it go on, and the run settles on whatever they return.

Each isolate carries the limit, and a deployment pays for it once per worker running at a time. isolated-vm documents the engine’s limit as a guideline, not a strict bound: a determined script can overshoot it by two to three times before it is stopped. So a container is sized well over the product of the worker count and the isolate bound, not exactly at it. Arithmetic for the whole container is in the operator guide, and that arithmetic is why memory is the bottleneck here and CPU is not. Raising the limit buys programs that hold more at once, but it does not fix a leak, because nothing survives the run either way.

Every execution needs a worker. When they are all busy the request waits, and past the queue bound it stops waiting. maxWorkers and maxQueue are both pool options:

import { ExecutorError, IsolateExecutor, rehydrateExecutorError } from '@supolka/codemode-executor'
const executor = new IsolateExecutor({ pool: { maxWorkers: 1, maxQueue: 1 } })
try {
await executor.execute({ code })
} catch (failure) {
const fault = rehydrateExecutorError(failure)
if (fault instanceof ExecutorError && fault.code === 'QUEUE_OVERFLOW') {
// retryable: a running execution will settle
}
}

rehydrateExecutorError answers ExecutorError | Error, because a failure whose code it does not recognize passes through as it was. instanceof narrows the type before you read code. With one worker and a queue of one, the third concurrent call rejects with:

The queue is at its bound of 1. Retry when a running execution settles.

That fault carries code: 'QUEUE_OVERFLOW' and retryable: true, so a caller acts on it without parsing anything. It is also the signal to scale, and an autoscaler reads the metric behind it, executor.pool.queue.depth. Setting the bound is a choice between two failures. A short queue turns away callers who would have been served in a moment. But a long one accepts work the pool cannot finish, and leaves the caller waiting without telling it anything. Its default assumes you would rather queue than reject. Lower it when your callers carry deadlines of their own.

Everything a program sends outward is bounded where it is built: the answer of the run, a thrown message, a tool call, an outbound request, a handle answer, and the console lines of the whole run together. Each of those is host memory the executor holds and a message it has to deliver, and every transport has a ceiling on one message. Without the bound, one line of a program could end the worker holding it. Past the bound the run is answered instead of being sent, and the text names the part that was too large, its size and the ceiling:

await executor.execute({ code: `async () => 'x'.repeat(9_000_000)` })
// error: "The run's answer is 9,000,013 characters, past the 8,388,608 one execution may send
// across the boundary at a time. Send a summary and keep the rest inside the run."

That part reads run's answer, tool call, outbound request or handle answer. On the way in, the same bound reaches the program as a rejection it can catch, in the same sentence with the part renamed:

await executor.execute({
code: `async () => {
try {
return await store.take('x'.repeat(9_000_000))
} catch (failure) {
return failure.message
}
}`,
providers: [{ name: 'store', tools: ['take'] }],
dispatch,
})
// { result: 'The tool call is 9,000,013 characters, past …', logs: [] }

Size counts the whole message and not the value inside it, its framing and escaping included. So nine million characters measure 9,000,013. Console output has a bound of its own over the whole run, counted across every line together. Whichever line meets it is dropped whole, a final warn line says what happened, and the run keeps going, but with its console silenced:

{ level: 'warn', text: "Console output reached this execution's limit of 2,097,152 characters and the rest was dropped. Return what the caller needs in the result." }

Neither bound is a setting. Both are declared beside the code in the protocol package and stated on the protocol page, the console bound at a quarter of the payload bound. They are sized together, so that a run at every bound at once still leaves the largest message far under the smallest ceiling in its path. Each text ends with the move a program can make: summarize what the run answers, and return what the caller needs in the result.

A server holds two numbers above every caller. No caller can raise either. The timeout cap applies to whatever a request asks for, so ask for sixty seconds against a daemon capped at one and you get one, and the model reads the bound that actually applied:

await executor.execute({ code: 'async () => { while (true) {} }', timeout: 60_000 })
// error: 'Execution timed out at its 1000 ms bound. Do less work per run and return the part
// that is already finished.'

That text names 1000 ms and not the 60_000 the request asked for. The response limit caps what one execution may return, measured on the serialized envelope. This one is a fault, not a text, because the server refuses to deliver what it measured:

await executor.execute({ code: `async () => 'x'.repeat(20_000)` })
// ExecutorError | RESPONSE_TOO_LARGE | retryable false
// 'The execution response of 20,031 bytes exceeds the response limit of 4,096 bytes.
// Return less data, or raise the response limit on the server.'

Notice which side each of those lands on. The timeout cap is invisible to your host and shapes what the model does next. The response limit rejects the call, and the model never learns of it. If you show failures to your model, turn a RESPONSE_TOO_LARGE into an instruction of your own. Both are operator settings. So are the grace period an execution gets after its bound and the count a worker retires at, and no caller sees either one. The operator guide’s table of every setting carries the whole list.

The bounds on this page hold a program while it runs. Failures says what a caller sees when one of them lands, and what to do with each kind. The threat model says what each bound is for and where it stops holding. Every run of the gate measures that they hold, under the method of the benchmark.