Skip to content

The sandbox

The sandbox owes a caller two promises: a program reaches exactly the capabilities its request granted and nothing besides, and the run ends. Every module in packages/executor/src/sandbox serves one or the other, and the places where the two pull against each other are worth reading twice. This chapter follows a single execution through the modules in the order it meets them.

What a caller may send and what comes back is the executor protocol; this page is the machinery underneath that contract. Every bound defends a boundary the threat model states. Runtime deviations covers the standard library installed before user code, and every place it answers differently from the standard that governs it.

Sandbox.execute in sandbox.ts takes a SandboxExecuteRequest and answers an ExecuteResult. Wherever user code is at fault it answers and does not reject: a bad provider name, a syntax error, a module the map cannot resolve, a timeout and a plain throw all come back inside the envelope.

  1. ConsoleCapture from console.ts is created before anything else, so a run the engine kills later still answers with what it logged.
  2. validateProviders from the protocol package checks the provider names. Failures return the composed text as the envelope error, and no isolate is created.
  3. createIsolate in isolate.ts builds the isolate from the startup snapshot under the request’s memory limit.
  4. RunClock in timeout.ts is constructed over that isolate and placed in a ClockSlot, the indirection that lets the wrapped host functions pause whichever clock owns the run.
  5. installConsole writes globalThis.console. Exceptions raised by an error listener also go there.
  6. chooseOutbound in fetch.ts resolves the three outbound states, and installRuntime in runtime.ts performs the handshake that hands the isolate the privileges it cannot hold on its own. Caller-supplied outbound functions are wrapped in wrapHostWait on the way in. Neither the native state nor the disabled one is.
  7. installTimers in timers.ts publishes setTimeout, setInterval, their clear functions and queueMicrotask over a host TimerRegistry.
  8. installDispatch in dispatch.ts publishes __dispatch, wrapping the caller’s dispatch function in wrapHostWait when there is one.
  9. separateImports from the protocol package splits the code into its leading import declarations and its body, and buildEntrySource assembles the entry module around them.
  10. linkModules in modules.ts builds the linker over the module map and fills the isolate’s loader slot so import() has an answer. Sandbox.execute then compiles the entry through it under the filename executor.js and instantiates it against its resolver.
  11. runWithTimeouts in timeout.ts evaluates the entry under the two timeouts and reads the envelope back.
  12. When the request asked to retain handles and the run succeeded, captureInvokeReference in handles.ts looks for the invoker the entry installed, and a grant hands the isolate to RetainedHandles and does not dispose it.
sequenceDiagram
    participant Host as Sandbox.execute
    participant Isolate as V8 isolate
    Host->>Isolate: createIsolate, restored from the startup snapshot
    Host->>Isolate: __consoleSink, __randomUUID, __randomBytes, __sendRequest, __performSubtleOperation, __hostDelay, __hostDispatch
    Note over Isolate: each install script deletes the name it read
    Host->>Isolate: entry module compiled and instantiated against the module map
    Host->>Isolate: RUN_SOURCE, which locks __resultJson and builds __timeoutExpired
    Host->>Isolate: entry.evaluate, bounded at timeout + grace
    Isolate-->>Host: __resultJson assigned, the envelope JSON
    Host-->>Host: parseEnvelope, then dispose or retain

Order matters inside the finally clause. It flips the dispatch guard inactive, empties the clock slot, and releases the clock before the isolate goes, so a host call settling late meets a closed clock instead of reading a disposed isolate. Retained executions keep their isolate and their timer registry, and every other run cancels its timers and disposes.

To the layer above it the sandbox announces one thing. onExecutionSettled receives the duration and whether the run ended in success or error, and it fires once. A throw from it is swallowed: an observer must not be able to fail the execution it observes.

createIsolate in isolate.ts constructs an ivm.Isolate, creates its context, and aliases the global. Two of its construction options carry what the request asked for. memoryLimit is the engine’s own limit in megabytes, and snapshot is the blob every isolate of this process is restored from.

Both remaining options keep a decision inside the sandbox that isolated-vm would otherwise make for it. importModuleDynamically reads through the loader slot on the returned handle: isolated-vm takes that callback at construction, while the linker that answers an import needs the isolate to exist first. linkModules fills the slot once the linker is built. onUnhandledRejection is installed as an empty function. isolated-vm ends a task with a microtask checkpoint and then throws the value of any promise rejected with no handler out of whatever call happened to be running. But with a handler in place the run reports its own outcome.

After the context exists, context.global.set('globalThis', jail.derefInto()) makes the host-side installs and user code see one and the same global object.

runtime.ts owns the snapshot. readRuntimeSnapshot memoizes ivm.Isolate.createSnapshot over RUNTIME_SOURCE, the generated bundle in runtime-bundle.generated.ts that tools/executor/generate-runtime-bundle.ts produces from sandbox/runtime. Once per process, V8 serializes the heap the runtime builds. Every isolate then starts from that copy, and no isolate parses and runs the bundle again. Sandbox reads the snapshot in a field initializer, so a process that composes its sandbox at startup holds the blob before its first request arrives. In pool/child-entry.ts the pool child composes exactly one sandbox.

Taking a snapshot goes against advice. isolated-vm’s documentation opens its createSnapshot entry by recommending against the feature, because it runs outside the isolate protection the rest of the library gives and newer V8 features may fail to serialize. But the module’s doc comment states the terms this repository takes it on. Only this package’s own generated bundle is ever handed to the unprotected isolate, and whatever that bundle needs from a snapshot is read back at the seam on every run. Reading it back is covered under where a change is proven in the runtime chapter.

Some state cannot survive serialization, and a second installation step exists for exactly that. sandbox/runtime/index.ts names what belongs to it, and the runtime chapter explains each one: the moment the clock starts, the listener list of the global object, and the closure of the allocation paths the memory limit cannot see.

installRuntime sets the host references below and then runs BINDINGS_SOURCE. That script reads each name, deletes it, and passes the functions into the __hostBindings handshake the snapshot published. User code meets the capability and never the reference behind it. When __hostBindings is absent the script throws The isolate was created without the runtime snapshot readRuntimeSnapshot answers. That text names the real cause. Otherwise the script would fail on a global it cannot explain.

Reference What it serves How the value crosses
__randomUUID crypto.randomUUID synchronously, as the string
__randomBytes crypto.getRandomValues synchronously, with the buffer copied out
__sendRequest fetch asynchronously, arguments and result copied
__performSubtleOperation crypto.subtle asynchronously, arguments and result copied

Two conditions no request can check for itself have to hold in the process running this sandbox. isolate.ts carries an assertion for each. assertNoNodeSnapshot creates a trivial isolate and runs 1 + 1 in it. Whether a process was forked with --no-node-snapshot is answered reliably by whether an isolate actually runs, and not by parsing execArgv. But nothing calls that assertion on a request path: it names a condition a host process has to meet, and pool/pool.ts meets it by passing the flag as its default childExecArgv.

assertEngineServesSandbox covers the changes to isolated-vm this sandbox needs. They arrive through the patch this repository keeps under patches/. ENGINE_PROBES holds one probe per change: the callback that answers import(), the rejection handler createIsolate installs, and the settlement of evaluate. Each probe watches for the behaviour, not for the surface, because a build that takes an option and ignores it looks the same from outside. For import(), the probe compiles import("probe") and waits for the embedder callback to fire. For the rejection handler, it leaves an orphan rejection behind and makes a second call to drain the microtask queue. For evaluate, it runs a module with a top-level await and reads whether the work past the await finished. Probes that hang are answered as a failure once ENGINE_PROBE_TIMEOUT elapses, since an unanswered check would otherwise read as a working engine.

Where that check runs decides how the failure reaches a human. Each missing behaviour is named in the composed failure, and the first thrown cause travels with it, so an engine that cannot create isolates at all reports that and not a missing patch. pool/child-entry.ts starts the check when it constructs its API object and awaits it inside heartbeat. A child whose engine cannot serve this sandbox therefore never answers a heartbeat, and the reason reaches the caller and not only the child’s stderr. Without the patch V8 rejects every import() with Not supported. One refusal at startup replaces executions that quietly lose a dynamic import.

A request’s code is an arrow expression, optionally preceded by static import declarations, as the entry contract states. buildEntrySource wraps it without rewriting it. Running the real builder over import { greet } from 'greeter' followed by async () => greet(await files.read('notes.txt')), with one provider named files, produces this module, with the codec elided:

import { greet } from 'greeter'
/* DISPATCH_CODEC_SOURCE */
const files = new Proxy({}, {
get: (target, toolName) => {
if (Object.prototype.hasOwnProperty.call(target, toolName)) return target[toolName];
if (typeof toolName === 'symbol' || toolName === 'then') return undefined;
return async (...args) => {
const resultJson = await __dispatch("files", String(toolName), __stringifyForDispatch(args));
let data;
try { data = __parseForDispatch(resultJson); } catch { data = null; }
if (data === null || typeof data !== 'object') throw new Error('The dispatch answered outside the result envelope.');
if (data.error) throw new Error(data.error);
return data.result;
};
},
});
globalThis.__resultJson = await (async () => {
try {
const expired = globalThis.__timeoutExpired.then(() => {
throw new Error("Execution timed out at its 60000 ms bound. Do less work per run and return the part that is already finished.");
});
const result = await Promise.race([(
async () => greet(await files.read('notes.txt'))
)(), expired]);
return __stringifyForDispatch({ result });
} catch (error) {
return __stringifyForDispatch({ error: error instanceof Error ? error.message : String(error) });
}
})();

From the top, every part of that module is there because some plainer arrangement fails in a particular way.

The codec comes first. DISPATCH_CODEC_SOURCE in codec.ts is the dispatch codec of the protocol package written out as source text. The sandbox cannot import from the host. It runs at module scope, ahead of the user arrow, and it binds every built-in it uses right there. Reading JSON through the global object at call time would put the result envelope in reach of user code, since assigning globalThis.JSON would then decide what the run reports. But btoa and atob are the exception, read off the global object and never named directly, because the codec source also runs in isolates the runtime has not been installed into. Binary values cross as a tagged base64 envelope, and the encoder spreads the bytes into String.fromCharCode in fixed slices, since spreading a large buffer in one call overflows the argument stack. codec.spec.ts drives both faces of the codec over a shared value table, a payload past that slice boundary included, and requires their wire output to be identical. That spec also asserts that every identifier the source declares is a reserved provider name.

Then the provider proxies. buildProviderSources in proxy-source.ts emits one proxy per descriptor and puts every prelude after all of them. Provider source commonly reaches its own namespace, and source running ahead of the declarations would die in the temporal dead zone of its own const. A descriptor carrying proxySource replaces the generated proxy with that text verbatim. The get trap answers a property written onto the proxy first, so a provider can hang helpers on its namespace. But it answers neither symbol keys nor then: symbol keys serve inspection protocols and never name a tool, and a proxy that answered then would be thenable, so awaiting the provider itself would invoke a tool named then. An absent positionalArgs selects the spread convention, and only an explicit false selects the single-object convention, where a missing argument serializes as {}.

The race sits at the bottom. The user arrow occupies its own lines, so a trailing line comment in user code cannot swallow the invocation, and sandbox.spec.ts pins that with async () => 1 // done. It is invoked and raced against __timeoutExpired, the run clock’s own expiry and not a timer of the isolate’s. So a run waiting on the host is not ended by a bound it has not spent. Either outcome of the race, and any throw the arrow produced, lands in one __stringifyForDispatch call.

__resultJson is the one name in that module the entry does not define. RUN_SOURCE in timeout.ts runs after the entry is instantiated and before it is evaluated, and it defines __resultJson as an accessor whose setter resolves a promise stored as __resultReady. The entry’s own assignment is therefore the completion signal. That signal is needed because module.evaluate with promise: true returns before a top-level-await entry settles and leaves the host with nothing to wait on. Its setter keeps the first value and ignores later ones, so a second report cannot displace the first. __resultJson is defined as a non-configurable accessor and __resultReady as a non-configurable, non-writable value, so user code can neither replace the accessor nor spoof the signal. timeout.spec.ts pins that redefining __resultJson meets Cannot redefine, and that assigning __resultReady meets a read-only failure. RUN_SOURCE also takes the __hostTimeout reference, deletes the name, and calls the reference once to build __timeoutExpired, all before any provider or user source runs.

On the way back, parseEnvelope in timeout.ts reads the string. Envelopes longer than PAYLOAD_LIMIT are never sent onward, and each is reported as a PayloadTooLargeError naming the run’s answer. A value that is not a string, and JSON that parses to something other than an object, both collapse to an undefined result. User code owns the payload, and an answer that is not an outcome must not reach the caller as one. But JSON that does not parse at all throws out of the read and is reported as an error envelope.

Every request carries a timeout and a grace. Timeout is the protocol’s bound and reaches the sandbox from the caller. Grace is the executor’s own headroom past it, DEFAULT_GRACE in packages/executor/src/executor/defaults.ts, and it is not part of the wire contract. runWithTimeouts computes totalTimeout as Math.min(timeout + grace, MAX_DELAY), where MAX_DELAY in timers.ts is the widest delay a 32-bit timer accepts.

RunClock is the run clock the protocol chapter describes. elapsed() answers the segments already closed plus the one open now. Every edge in or out of a host wait closes the open segment into that running total. Only the measurement of the open segment changes: wall time since the last edge while no wait is open, and isolate.wallTime since the wait opened while at least one wait is open and countHostWaits is false. isolate.wallTime advances only while the isolate executes, so guest compute keeps spending the bound whether or not a host call is in flight, while a passive await on a host promise costs almost nothing. Setting countHostWaits leaves every segment on wall time. A wait then makes no difference.

wrapHostWait opens and closes a wait. It wraps a function so that every call through it opens a wait before the host sees the call and closes it when the call settles either way. The wait closes on the clock that opened it, not on whichever clock the slot holds by then. sandbox.ts applies it to the caller’s dispatch function and to a caller-supplied outbound function. But it does not apply to a native outbound request, because that request goes to a URL the guest chose with no bound of its own, and pausing there would let the guest occupy its worker without bound. It does not apply to crypto.subtle either: that is the guest’s own work carried out elsewhere.

Nesting is counted, so waits close only at the last leave. onHostWaitChanged announces the outermost edges alone. Announcing runs inside enterHostWait on the dispatch path itself, so a throwing subscriber is swallowed and does not fail the tool call it merely observes. The pool child forwards those edges to its parent, and the layer above the run pauses with it.

expire(bound) cannot be one timer. While the clock is paused the counted time grows at the isolate’s own pace, so the timer fires at the earliest moment the bound could be reached and reschedules on what is actually counted by then. A paused clock rechecks no faster than PAUSED_RECHECK. That interval is also the most an expiry can land late when guest compute crosses the bound in the middle of a wait. release() abandons the promise and never resolves it, the same never-settle answer never() gives a cancelled sandbox timer.

Both readings of isolate.wallTime are wrapped in a try. A memory-limit disposal lands from the isolate’s own thread, so the getter can start throwing between any check and the read. Catching keeps the clock alive across that.

flowchart TD
    clock["RunClock: wall time while guest code owns the run,<br/>isolate execution time while a caller-served call is in flight"]
    clock -->|"expire(timeout)"| l1["__timeoutExpired, raced inside the isolate<br/>against the invoked arrow"]
    clock -->|"expire(timeout + grace)"| l2["host race in runWithTimeouts,<br/>for an isolate that can no longer take a call"]
    engine["entry.evaluate, timeout + grace on the engine's own timer thread"]
    l1 --> env["the envelope carries ExecutionTimeoutError's text"]
    l2 --> env
    engine -->|"Script execution timed out."| rw["rewriteEngineTermination corroborates<br/>the text against elapsed and disposed"]
    rw --> env

Layer 1 is the race inside the entry, and sandbox.ts names it that. It fires at the timeout. Layer 2 is the pair at timeout + grace: the engine’s evaluate timeout, which bounds only the synchronous portion of execution, and the host race. Without that host race an entry that never reports would hang the caller instead of falling out as a timeout. Both readings of the outcome, the delivery of the envelope and the recovery read after a failure, race that host expiry. A call into the isolate waits for the isolate to be free and carries no bound of its own. An entry that leaves a spinning timer behind would otherwise hold the read open for as long as that timer runs.

What the run does What ends it What the caller reads
Yields and outlives its timeout the layer-1 race on __timeoutExpired ExecutionTimeoutError’s text, thrown inside the entry and caught by the entry’s own catch
Spins without yielding the engine’s evaluate timeout at timeout + grace the engine’s Script execution timed out., rewritten
Exhausts the memory limit the engine disposes the isolate Isolate was disposed during execution due to memory limit, rewritten
Wedges the isolate so no call lands, or never assigns __resultJson the host race on clock.expire(timeout + grace) ExecutionTimeoutError’s text composed on the host
Throws past its first yield the rejected evaluation the thrown message as it reads

sandbox.spec.ts and timeout.spec.ts between them drive every row of that table, the first through a whole execution and the second against runWithTimeouts alone.

rewriteEngineTermination performs the two rewrites in the middle of the table. It demands corroboration for each, because user code can throw the same words. A timeout text is rewritten when the counted time has reached the bound, with CLOCK_TOLERANCE allowed for the disagreement between the engine’s own timer thread and Date.now. A memory text is rewritten when the isolate reads disposed. But a program throwing Script execution timed out. five milliseconds into a three-second run keeps its own words.

A bare V8 context has no timer facility. installTimers publishes one over the host. On the isolate side one identifier space and one cancel set serve both kinds of timer, because clearTimeout and clearInterval are interchangeable on the platform. Callbacks that are not functions are turned down before anything is scheduled, with the text workerd writes: Failed to execute 'setTimeout' on 'ServiceWorkerGlobalScope': parameter 1 is not of type 'function'. An interval schedules its next run before it calls back. A throwing callback therefore cannot end it. Delay coercion is split between the two sides: the isolate collapses a non-numeric delay to zero with Number(delay) || 0, and the host clamps the range into [0, MAX_DELAY], because Node treats a delay above the signed 32-bit range as one millisecond and warns.

installTimers runs after installRuntime. Its queueMicrotask is a plain assignment onto a writable global, so it stands where the runtime’s own published operation stood. A run therefore reaches the timer module’s version. That version schedules the callback on Promise.resolve().then and stays silent when it throws.

TimerRegistry holds the pending host handles of one execution. After cancelAll it never sets a timer again, and delay answers never() instead. The reference the isolate calls also checks isolate.isDisposed after the delay elapses and answers never() when it holds. No settlement may be delivered into a dead isolate: resolving would run a callback the execution no longer owns, and rejecting would raise an error user code never caused. The Workers runtime answers the same way, and an asynchronous call a handler neither awaited nor passed to ctx.waitUntil can be cancelled once the invocation ends.

But a guest timer opens no host wait, so it never pauses the clock, and a sleeping run spends whatever the clock counts. While no host call is open the clock counts wall time, and the delay costs its full length. While one is in flight it counts isolate.wallTime, and a pending timer advances none of it, so the same delay is free. The callback itself runs in the isolate, so the work it does moves isolate.wallTime like any other guest compute.

sequenceDiagram
    participant User as user code
    participant Proxy as generated proxy
    participant Bridge as __dispatch
    participant Ref as installDispatch reference
    participant Tool as the caller's dispatch function
    User->>Proxy: files.read('notes.txt')
    Proxy->>Bridge: "files", "read", __stringifyForDispatch(args)
    Bridge->>Ref: three strings, copied out of the isolate
    Ref->>Tool: dispatch(provider, tool, argsJson)
    Tool-->>Ref: the envelope JSON, or a throw
    Ref-->>Bridge: ['returned', json] or ['thrown', text]
    Bridge-->>Proxy: the JSON string, or a rethrown Error
    Proxy-->>User: data.result, or a throw carrying data.error

Every tool call leaves the isolate as three strings and comes back as a copied two-element frame, a discriminator plus the payload. That payload is the only user data, and it is always a pre-serialized JSON string. The host reference answers a settled frame and never rejects, because isolated-vm delivers a rejection into the isolate while the host process still records it as unhandled. A failure therefore travels inside the frame and is rethrown behind the boundary by DISPATCH_SOURCE, which also keeps host stack traces out of the sandbox. That script deletes __hostDispatch after reading it, so user code never reaches the host reference.

installDispatch decides on the host side in this order. A call arriving after the run settled answers EXECUTION_COMPLETED_MESSAGE, the pinned Execution has already completed. That guard lives on the host because completion has to block a late call while the isolate may still be running. A call whose provider name, tool name and arguments exceed PAYLOAD_LIMIT together answers a PayloadTooLargeError naming the tool call: those three strings travel to whoever serves the tool, and for a pooled execution that is another process. A request that carries no dispatch function still publishes __dispatch, and a call through it answers the ToolNotFoundError envelope any unknown tool gets. Otherwise the caller’s function runs, and its answer or its failure becomes the frame. Unlike the timers install, this one carries no disposal guard: the reference only ever resolves, and isolated-vm drops a resolved settlement aimed at a disposed isolate without host-side effect. dispatch.spec.ts pins that directly.

On the way back into user code the generated proxy parses the answer through the codec, and an answer that is not an object rethrows The dispatch answered outside the result envelope.. An envelope carrying error is rethrown as an Error with that text, so a tool failure reaches user code as an ordinary catchable throw and, uncaught, becomes the envelope error of the run.

linkModules returns a compile function and a resolver, and fills the isolate’s loader slot on the way out. Before any of that, buildModuleMap normalizes every key through normalizeSpecifier. A second key that resolves onto a name already taken throws ModuleKeyCollisionError naming both spellings. moduleSource picks the kind of an entry. It reads the map with Object.hasOwn, so an identifier such as constructor or toString is not answered by Object.prototype.

Entry What is compiled
a bare string the string as an ES module
js the source as an ES module
cjs the CommonJS facade wrapCjs builds
json export default of the value, serialized
text export default of the string
data export default of an ArrayBuffer decoded through atob

But an entry carrying both js and cjs resolves as js everywhere. The require space skips it. Entries with none of the kinds throw UnsupportedModuleKindError, and a name the map does not carry throws ModuleNotFoundError. Because linking runs inside entry.instantiate, a static import that resolves nowhere reaches the caller through the outer catch of Sandbox.execute as an envelope error.

resolveSpecifier in resolver.ts is the one rule both the linker and the CommonJS facade use. Specifiers starting with /, ./ or ../ resolve against the directory of their importer, with the entry treated as the root. Bare specifiers resolve to a sibling of the importer when the map declares one, and otherwise stay an exact key, which keeps a namespaced name such as runtime:helpers untouched. resolveSpecifier walks the segments itself and never uses new URL, because a URL percent-encodes the path, so a key spelled my file.js would come back as my%20file.js and stop matching the map.

Compilation is memoized on the module name, and the memo holds the promise and not the settled module. Two branches of a diamond request their shared dependency concurrently, and a memo written only after the compile settles would hand each branch its own instance. The resolver only compiles, because V8 drives the linking of the whole transitive graph from instantiate, and a resolver that instantiated on its own would break import cycles and double-link shared dependencies. It reads the importing module’s name out of a WeakMap keyed on the module object isolated-vm hands back. A referrer the linker never compiled raises an INTERNAL fault, with no fallback to an exact-key match.

compile also carries the V8 code cache. Each source is keyed by its base64url SHA-256, a stored blob is consumed through an ExternalCopy and released afterwards, and a fresh compile produces a blob for the store. The store belongs to the process. pool/child-entry.ts gives its one sandbox a new Map(), so identical sources compile once per child while every execution still gets a fresh isolate. Stored blobs V8 rejects are ignored, and the source is parsed in full. modules.spec.ts proves that by zeroing a blob and requiring the module to work anyway.

Dynamic import goes through the loader slot. loadModule memoizes on the module name. A name the graph has already met is answered from the compile memo, since an import through the static graph has already run. A module the graph has not met is compiled, instantiated and evaluated, and those loads run one after another behind a serializing promise. The linker claims every module of a graph while it links: two loads reaching one module would fight over it, and a module another load is still evaluating has nothing to hand over yet. A cycle closed through import() reads the bindings the still-evaluating module has, and such a cycle reads the same thing anywhere else.

cjs.ts builds a self-contained facade per CommonJS entry. Each facade embeds every cjs source of the map, so nested require reaches any sibling, and it carries the require runtime with it. normalizeSpecifier and resolveSpecifier are interpolated into the facade as source text, so neither function may reference an import or an outer binding. The runtime follows Node where Node’s behaviour is observable. Every cache entry is seeded before its factory runs, and a circular require observes partial exports through it. A throwing factory is evicted, so the next require retries and never returns half-built exports. Top-level this is bound to module.exports, and require.resolve answers the resolved id or throws. Factories live in a Map because an object literal would route ids like __proto__ or toString through Object.prototype.

That shape shows through to the code inside the facade in two ways. Factory bodies inherit the strict mode of the enclosing ES module while Node runs CommonJS sloppy, so an assignment to an undeclared name throws. And each facade owns its cache, so a cjs module reached through two ESM-imported facades evaluates once per facade, not once per isolate.

chooseOutbound resolves the tri-state of the outbound setting into one function of the wire request. Null answers a rejection carrying OutboundDisabledError’s text, because the outbound contract promises a rejection and a caller is free to reach it with .catch. A function is used as it stands. Undefined means the executor performs the request itself, through the process’s own fetch, buffering the whole response body before it crosses into the isolate.

Both states that perform a request measure it first, the caller’s function and the native one. But the disabled state rejects without reading the request at all. measureRequest counts the URL, every header name and value, and the body bytes. A request past PAYLOAD_LIMIT throws a PayloadTooLargeError naming the outbound request. In native mode the bound applies as well, where no transport is crossed, because what the guest may build should not depend on which state it meets.

Behind outbound, the host reference never rejects. installRuntime wraps the chosen function so an answer crosses as ['returned', response] or ['thrown', text], and the runtime’s fetch turns a thrown frame into a TypeError carrying the text. Only a caller-supplied function pauses the run clock. Native mode is the guest’s own choice of URL performed by the host, so its latency counts toward the run.

Inside the isolate, console is a plain object built by CONSOLE_SOURCE. log, warn and error format their arguments by mapping String over them and joining with single spaces. That is the wire’s formatting rule. Methods the Console Standard carries beyond those three exist and write nothing, along with the profile, profileEnd and timeStamp Node adds, so code calling console.debug or console.time runs. But Node’s Console, console.context and console.createTask stay out, because each answers with an object whose shape this sandbox would have to invent.

Every line crosses through applySync, which blocks the isolate until the host push returns. Every line is therefore buffered on the host before the next sandbox instruction runs, and a hard timeout or a memory kill cannot lose captured output. Any failure of the sink is swallowed at the boundary, because applySync re-throws a host exception inside the isolate where user code could catch it and observe the instrumentation.

ConsoleCapture bounds what one run may write. The payload bound states the reason: captured text is host memory the executor holds and a message it has to deliver, and a transport has a ceiling on one message. Each line is charged LINE_COST, the punctuation of {"level":"error","text":}, around it, plus JSON.stringify(text).length. That second term is the cost of escaping the text on the wire, and it reaches six code units per character for the control characters a guest may write. Charging for the line’s own shape keeps console.log() with no arguments from being free, and console.spec.ts pins that a run writing nothing but empty lines still reaches the bound.

Capture stops at CONSOLE_CAPTURE_LIMIT. The line that does not fit under it is dropped whole, a final warn carrying CONSOLE_CAPTURE_MESSAGE states the bound, and the sink answers false. On the isolate side that answer stops every further crossing, so a run that writes without end costs no more than the words it already sent. console.spec.ts also drives a real isolate past the bound with long lines and counts the crossings. They stop as soon as the sink has answered false once. stop() is the other ending. Retained executions have already delivered their lines, so what their invocations write is dropped without a final line, since the run reached no bound.

retainHandles asks the sandbox to keep the isolate alive when the run resolves to a fetch handler. Capture happens where the value lives, in the entry’s own success path: HANDLE_CAPTURE_SOURCE replaces the ordinary return __stringifyForDispatch({ result }) tail, resolves the result to a bare function or to an object carrying a fetch method, and installs globalThis.__invokeHandle when it found one. But a result of any other shape reports as usual, and nothing is retained.

Before it decides anything, the tail opens with delete globalThis.__invokeHandle. Without that, user code could plant its own invoker and pass for a grant. A plant the delete cannot remove throws in strict module code, and the run reports that error instead of granting. handles.spec.ts pins both directions. On a grant the envelope stays empty, because a function cannot cross the boundary as a value. The invoker lives on the global scope because the handler and the Request and Response types exist only inside the isolate, and the host reaches it through a reference once the run has settled. captureInvokeReference reads typeof globalThis.__invokeHandle and takes the reference when it is a function. Presence of the invoker is the grant, and its absence is reported in-band by a result that carries no executionId.

RetainedHandles owns the retained half. retain mints an executionId with randomUUID. It keeps the isolate handle, the timer registry, the clock slot and the bounds of the execution. invoke rejects an unknown executionId with HandlesClosedError, and a name outside GRANTED_HANDLES with HandleNotFoundError. Invocations run one at a time in arrival order, chained on a per-execution promise. The isolate serializes the synchronous portions anyway, and a bound measuring one invocation alone is the honest reading.

Each invocation runs under the bounds the execution was granted, with a fresh RunClock placed in the same clock slot. The dispatch and outbound wraps installed once per isolate therefore pause the clock that owns the run now. That invoker call carries the engine timeout at timeout + grace as the backstop for a synchronous spin, and it is raced against clock.expire(timeout). The expiry answers nothing while the invoker always answers an object, so an empty answer is the bound landing on a parked run.

Invocations that go badly enough take the whole retained execution with them, because disposal is the only thing that stops a wedged isolate. That happens when the bound lands, when the engine text rewrites into a timeout or a memory failure, and when the isolate reads disposed without this layer having disposed it. One more case has no engine text to read at all: the isolate was already dead when the invocation arrived. Timers of the execution stay alive between invocations, and the memory limit ends what they do. Engine words differ when the death lands between calls, so the rewrite alone cannot recognize it. But a handler’s own throw is none of these and crosses as its message, with the handles intact.

The answer is validated twice. Inside the isolate the invoker rejects anything that is not a Response, and assertResponseWireShape on the host checks the status, the status text, the header pairs and the body again. Both checks are needed, because the invoker lives on a writable global and a Response subclass can override what the serialization reads. assertResponseWireShape also measures the status text, the headers and the body against PAYLOAD_LIMIT.

The sandbox holds no idle bound of its own. Retained executions end with closeHandles, with a run bound, or at the idle timeout the pool owns, handlesIdleTimeout in pool/pool.ts. A caller that arrives afterwards meets the HANDLES_CLOSED fault of the handles chapter.

crypto.ts is the host’s half of crypto.subtle. The isolate holds no cryptography of its own. It names an operation and hands the arguments over as data, the bridge performs the operation on the host’s webcrypto, and the outcome comes back. Every operation of the interface answers a promise, so the crossing costs nothing an await was not already paying for.

Key material never enters the isolate. A CryptoKey the host produced is held in a Map, and the isolate receives a description carrying the number the key answers to along with its type, extractability, algorithm and usages. On the way in, one walk over the arguments swaps every { cryptoKey: n } back for the key it names, reaching a key nested inside an algorithm member such as the public key of ECDH. A number the host does not hold answers The provided value is not of type CryptoKey. On the way out, a single level of a record is walked, and generateKey needs exactly that for its pair. A key imported as non-extractable therefore stays that way, because the material sits where the code inside the isolate cannot reach it whatever that code does to the object it holds.

The operation name arrives from the isolate and is answered from a fixed list of the methods SubtleCrypto declares. It is never reached on the interface, because the host’s implementation carries more than the standard does. A name outside the list answers a TypeError naming it.

This work happens on the host’s thread pool, where the memory limit never meters it and no interrupt reaches an operation already running. But the deadline still lands, because crypto.subtle opens no host wait and the run clock counts straight through it. Ending the run leaves a started operation to finish, so the bridge carries bounds of its own. KEY_LIMIT caps how many keys one execution may create. A key lives in the host process, where the memory limit cannot see it. Operations run one at a time on a per-execution chain, so an execution occupies one thread of the host and not the pool. That matters because the caller decides how much work one operation is: a key derivation states its own iteration count. The bridge is created with () => !handle.isolate.isDisposed, so an operation still waiting when the isolate is gone never starts and answers The execution has ended. instead. One bridge serves one isolate. The keys of an execution leave with that execution, and no other can name them.

Failures cross as a settled frame carrying the exception’s name beside its text, because code inside the isolate branches on the name, and the runtime chapter says what a program catching one reads. Runtime deviations carries what the bridge changes for code written against the standard: the cap on keys, and the members of an algorithm object that never cross.