Bounds
Every execution runs under six bounds:
| Bound | Where it is set | Who reads the failure |
|---|---|---|
| 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 |
The published API lists each option’s default and each constant’s value.
Timeout
Section titled “Timeout”Set timeout on the request, or on the executor to cover every run:
await executor.execute({ code, timeout: 5_000 })timeout: 5_000 gives that run five seconds, and a request that sets none gets the executor’s default. A program still running when the deadline passes is terminated, and the model reads the bound the execution actually ran under:
Execution timed out at its 5000 ms bound. Do less work per run and return the part that is already finished.
Time is measured on the host, not from inside the guest. That 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. So the ending depends on what the guest was doing when the deadline passed:
Program under timeout: 500 |
What ends the run | When the caller reads the text, measured on a warm pool |
|---|---|---|
await new Promise((r) => setTimeout(r, 60_000)) |
The entry module, from inside the isolate | At the bound, about 500 ms |
while (true) {} |
The worker, from outside the isolate | One grace period after the bound, about 1500 ms |
Both runs report the same text, and the isolate is disposed either way. The gap between them is about a second, which is the grace period the second run waited out after its bound. Daemon operators set that grace, and in your own process it is fixed. Terminating the worker takes longer still: the pool waits out a further delay while the builtin finishes and then kills the child. No caller waits for that, but a pool of one worker has no spare capacity until it passes.
Run clock
Section titled “Run clock”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 timedispatch takes three seconds there and execute still resolves 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 reply does not end a run whose own work stayed inside its bound. While one of your functions is in flight the clock counts only the time the isolate spends executing, and otherwise it counts wall time:
| What the run does | Does it spend the bound |
|---|---|
| Guest compute | Yes, and it goes on counting while your dispatch is in flight |
A guest setTimeout delay, with nothing of yours in flight |
Yes |
A guest setTimeout delay, while your dispatch or your outbound function is in flight |
No, though the work its callback does when it fires counts |
| A tool call your dispatch handles | No |
| A request your outbound function handles | 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 value. Dialects that measure wall-clock time 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.
Choosing a timeout
Section titled “Choosing a timeout”A run that reaches its bound makes the caller wait that long, and a worker is unavailable for the same time. Two numbers frame the choice: 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 a stuck program occupies a worker until the deadline. 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.
Memory limit
Section titled “Memory limit”V8 enforces the memory limit on the isolate’s heap, and memoryLimit is an executor option no request field overrides:
const executor = new IsolateExecutor({ memoryLimit: 128 })memoryLimit is in megabytes, and every isolate the executor starts gets that number. 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. The engine ends a program that fills the heap in steps, 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 continue, and the run’s result is whatever they return. A program that does not catch it ends the run, and error reads that same message.
Choosing a limit
Section titled “Choosing a limit”The limit applies to every isolate, so 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 a whole container is in the operator guide, where memory runs out before CPU does. A higher limit lets a program allocate more at once. It does not fix a leak, because the isolate is disposed at the end of every run either way.
Every execution needs a worker, and by default a worker runs one execution at a time. Requests wait while every worker is busy, and execute rejects once the queue is full. 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 returns 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 has code: 'QUEUE_OVERFLOW' and retryable: true, so a caller branches on the code and parses nothing. executor.pool.queue.depth is the metric behind it, and an autoscaler scales on that depth instead of on CPU. 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. The shipped default assumes you would rather queue than reject. Lower maxQueue when your callers have deadlines of their own.
Payload and console bounds
Section titled “Payload and console bounds”Every message a program sends outward is bounded where it is built: the run’s answer, 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 keeps and a message it has to deliver, and every transport in the path caps one message. Without the bound, one line of a program could end the worker that sends it. Past the bound the executor reports the failure in place of the message, 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 characters 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. A tool call meets the same bound, and there it rejects at the call site, in the same sentence with the part renamed:
const { result } = 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 the 8,388,608 characters one execution may// send across the boundary at a time. Send a summary and keep the rest inside the run.'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 is bounded separately, over the whole run and across every line together. Whichever line meets that bound is dropped whole, a final warn line says what happened, and the run continues 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." }That 2,097,152 is a quarter of the payload bound, and both numbers are constants the protocol package declares and the protocol page states. 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.
Two bounds an operator adds
Section titled “Two bounds an operator adds”Operators set two more bounds above every caller, and a request cannot raise either. MAX_TIMEOUT_MS caps whatever a request asks for, so ask for sixty seconds against a daemon capped at one second and the run gets one second:
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. RESPONSE_LIMIT_BYTES caps what one execution may return, measured on the serialized envelope. This one is a fault and 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.'The timeout cap is invisible to your host and shapes what the model does next. Past the response limit the server rejects the call instead, 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. The grace period after a bound and the count a worker retires at are two more, and a caller sees neither. All four are in the operator guide’s table, with the rest of a server’s settings.
The six bounds on this page apply while a program runs. Failures says what a caller sees when one of them fires, and what to do with each kind. The threat model says what each bound is for and where it stops working. Every run of pnpm verify measures that they hold, under the method of the benchmark.