Sandbox
Every module in packages/executor/src/sandbox exists for one of two properties: a program reaches the capabilities its request granted and nothing else, and the run finishes. This chapter follows one execution through those modules in the order it meets them.
The executor protocol states what a caller may send and what comes back, and this page is the machinery under that contract. The threat model states the boundary each bound here defends. Runtime deviations covers the standard library installed before user code, and every place it differs from the standard that governs it.
One execution, end to end
Section titled “One execution, end to end”Sandbox.execute in sandbox.ts takes a SandboxExecuteRequest and returns a promise for an ExecuteResult. Where user code is at fault that promise resolves and never rejects: 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.
ConsoleCapturefromconsole.tsis constructed before anything else, so a run the engine kills later still comes back with the lines it wrote.validateProvidersfrom the protocol package checks the provider names. A failure becomes the envelope error, and no isolate is created.createIsolateinisolate.tsbuilds the isolate from the startup snapshot under the request’s memory limit.RunClockintimeout.tsis constructed over that isolate and placed in aClockSlot, so the wrapped host functions pause whichever clock owns the run.installConsolepublishesglobalThis.console.chooseOutboundinfetch.tsresolves the three outbound states, andinstallRuntimeinruntime.tsperforms the handshake that hands the isolate the privileges it cannot hold on its own. An outbound function the caller supplied is wrapped inwrapHostWaiton the way in. Neither the native state nor the disabled one is.installTimersintimers.tspublishessetTimeout,setInterval, their clear functions andqueueMicrotaskover a hostTimerRegistry.installDispatchindispatch.tspublishes__dispatch, wrapping the caller’s dispatch function inwrapHostWaitwhen there is one.separateImportsfrom the protocol package splits the code into its leading import declarations and its body, andbuildEntrySourceassembles the entry module around them.linkModulesinmodules.tsbuilds the linker over the module map and fills the isolate’s loader slot, which theimportModuleDynamicallycallback reads on everyimport().Sandbox.executethen compiles the entry through it under the filenameexecutor.jsand instantiates it against its resolver.runWithTimeoutsintimeout.tsevaluates the entry under the two timeouts and reads the envelope back.- When the request asked to retain handles and the run succeeded,
captureInvokeReferenceinhandles.tslooks for the invoker the entry installed, and a grant moves the isolate toRetainedHandlesinstead of disposing 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 defines the completion accessor 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
The finally clause flips the dispatch guard inactive, empties the clock slot and releases the clock, and it does all three before the isolate is disposed, so a host call settling late meets a released clock instead of a disposed isolate. A retained execution keeps its isolate and its timer registry. Every other run cancels its timers and disposes.
onExecutionSettled is the one hook the sandbox offers the layer above it. It fires once per execution, with the duration and with success or error, and a throw from it is caught and dropped, because an observer must not fail the execution it observes.
Isolate and its startup snapshot
Section titled “Isolate and its startup snapshot”createIsolate in isolate.ts constructs an ivm.Isolate, creates its context and aliases the global. memoryLimit is the engine’s own limit in megabytes and comes from the request. snapshot is the blob every isolate of this process is restored from, and the Sandbox supplies it, not the request.
The other two options createIsolate passes keep inside the sandbox a decision isolated-vm would otherwise make. importModuleDynamically reads the loader slot on the returned handle, because isolated-vm takes that callback at construction while the linker behind it needs the isolate to exist first, and linkModules fills the slot once the linker is built. onUnhandledRejection is an empty function. Without a handler 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, so one stray rejection inside user code fails an unrelated call. With the handler the value is dropped and 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. V8 serializes the heap that bundle builds once per process, every isolate 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 has the blob before its first request arrives. The pool child in pool/child-entry.ts composes exactly one sandbox.
isolated-vm recommends against createSnapshot in the opening line of its entry: the script it takes runs outside the isolate protection the rest of the library gives, so too much memory crashes the process and an endless loop stalls it, and a newer V8 feature may fail to serialize at all. The only script ever handed to that unprotected isolate is this package’s own generated bundle, and whatever the bundle needs from a snapshot is read back at the seam on every run, under where a change is proven.
Three pieces of state cannot survive serialization, so the runtime installs them in a second step: the moment the clock starts, the listener list of the global object, and the closure of the allocation paths the memory limit cannot see. sandbox/runtime/index.ts names them, and the runtime chapter says why each one fails a snapshot.
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, so 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 cause; without it the script would fail on a global it cannot explain.
| Reference | What it implements | 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, and isolate.ts has an assertion for each. assertNoNodeSnapshot creates a trivial isolate and runs 1 + 1 in it, because whether a process was forked with --no-node-snapshot shows in whether an isolate runs at all and not in execArgv. But neither assertion runs on a request path. Each names a condition a host process has to meet, and pool/pool.ts meets the flag one by defaulting childExecArgv to ['--no-node-snapshot'].
assertEngineServesSandbox runs assertNoNodeSnapshot first and then three probes. The peer range in packages/executor/package.json admits an isolated-vm that predates these behaviours, and a release may have some of them and not others. Each probe performs the behaviour instead of reading the option back, because a build that takes an option and ignores it looks the same from outside.
Behaviour ENGINE_PROBES names |
How the probe performs it |
|---|---|
answer import() through the embedder callback |
compiles import("probe") and reads whether the callback fired |
| report an unhandled rejection to the embedder | leaves an orphan rejection behind, then makes a second call to drain the microtask queue |
settle evaluate once a module has finished |
evaluates a module with a top-level await and reads whether the work past the await ran |
A probe that hangs counts as a failure once ENGINE_PROBE_TIMEOUT elapses, because an unanswered check would otherwise read as a working engine. The three run together, so the check spends one deadline and not their sum.
pool/child-entry.ts starts the check when it constructs its API object and awaits it inside heartbeat, so a child on an engine that cannot serve this sandbox never reports healthy and the reason reaches the caller instead of the child’s stderr alone. The composed failure names every missing behaviour and takes the first thrown failure as its cause. Without the embedder callback V8 rejects every import() with Not supported, so one refusal at startup replaces a run of executions that quietly lose a dynamic import.
Entry module
Section titled “Entry module”A request’s code is an arrow expression, optionally preceded by static import declarations, as the entry contract states, and 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) }); }})();DISPATCH_CODEC_SOURCE in codec.ts is the protocol package’s dispatch codec written out as source text, because the sandbox cannot import from the host. It runs at module scope ahead of the user arrow and 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: assigning globalThis.JSON would then decide what the run reports. 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.
buildProviderSources in proxy-source.ts emits one proxy per descriptor and puts every prelude after all of them, because 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 with proxySource replaces the generated proxy with that text verbatim. The get trap returns a property written onto the proxy before it reaches for a tool, so a provider can hang helpers on its namespace. But it returns undefined for a symbol key and for then: symbol keys belong to inspection protocols and never name a tool, and a proxy that returned a function for 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 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. The entry invokes it and races it against __timeoutExpired, the run clock’s 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, goes through 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 defines __resultJson as an accessor whose setter resolves a promise stored under __resultReady. The entry’s assignment to that name is the completion signal. Without one the host has nothing to wait on, because module.evaluate with promise: true returns before a top-level-await entry settles. The setter keeps the first value and ignores later ones, so a second report cannot displace the first. RUN_SOURCE also takes the __hostTimeout reference, deletes the name, and calls it once to build __timeoutExpired, all before any provider or user source runs.
Both completion names are defined without flags: __resultJson is a non-configurable accessor and __resultReady a non-configurable, non-writable value, so user code can replace neither. timeout.spec.ts pins that redefining __resultJson meets Cannot redefine and that assigning __resultReady meets a read only failure. The setter stays reachable, and a program that assigns through it reports its own run:
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor({ timeout: 3_000 })
console.log( await executor.execute({ code: `async () => { globalThis.__resultJson = JSON.stringify({ result: 'the program reported this' }) await new Promise(() => {}) return 'the arrow never reaches here' }`, outbound: null, }),)
console.log( await executor.execute({ code: `async () => { try { Object.defineProperty(globalThis, '__resultJson', { value: 'replaced' }) } catch (failure) { return failure.message } return 'the accessor was replaced' }`, outbound: null, }),)
await executor.dispose(){ result: 'the program reported this', logs: [] }{ result: 'Cannot redefine property: __resultJson', logs: [] }The first program never returns: it awaits new Promise(() => {}) and the run settles anyway, on the value that program assigns. A return reaches the envelope through the same setter, so an early assignment writes the payload the program already owns and touches nothing outside its own run.
On the way back, parseEnvelope in timeout.ts reads the string. An envelope longer than PAYLOAD_LIMIT is never sent onward and 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, because user code owns the payload and a value 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.
Two timeouts and the run clock
Section titled “Two timeouts and the run clock”Every request arrives with a timeout and a grace. timeout is the protocol’s bound and comes from the caller. grace is the executor’s own headroom past it, defaulted by DEFAULT_GRACE in packages/executor/src/executor/defaults.ts, and no 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() returns the segments already closed plus the one open now. With countHostWaits false the outermost edge in or out of a host wait closes the open segment into that total, and an inner edge closes nothing. Only the measurement of the open segment changes:
State of the run, with countHostWaits false |
What the open segment measures |
|---|---|
| no host wait open | wall time since the clock started, or since it last closed a wait |
| at least one host wait open | isolate.wallTime since the wait opened |
Setting countHostWaits leaves every segment on wall time. A wait then makes no difference. isolate.wallTime advances only while the isolate executes, so guest compute spends the bound whether or not a host call is in flight, and a passive await on a host promise costs almost nothing.
wrapHostWait 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, and not on whichever clock the slot holds by then. sandbox.ts applies it to the caller’s dispatch function and to an outbound function the caller supplied, because a caller can bound its own functions. 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.
The clock counts nesting, so a wait closes only at the last leave and onHostWaitChanged fires on the outermost edges alone. That call runs inside enterHostWait on the dispatch path itself, so a throw from a subscriber is caught and dropped and cannot fail the tool call it observes. The pool child forwards both edges to its parent, and the layer above the run pauses with it.
expire(bound) reschedules instead of setting one timer. While the clock is paused the counted time grows at the isolate’s own pace, so each timer fires at the earliest moment the bound could be reached and the check reschedules on what is actually counted by then. A paused clock rechecks no faster than PAUSED_RECHECK, and that interval is also the most an expiry can fire late when guest compute crosses the bound in the middle of a wait. release() clears the pending timers and leaves the promise unresolved, the same way never() leaves a cancelled sandbox timer.
Both readings of isolate.wallTime sit inside a try. A memory-limit disposal arrives from the isolate’s own thread, so the getter can start throwing between any check and the read, and 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 reports ExecutionTimeoutError's text"]
l2 --> env
engine -->|"Script execution timed out."| rw["rewriteEngineTermination corroborates<br/>the text against elapsed and disposed"]
rw --> env
Layer 1 fires at the timeout, inside the entry, and sandbox.ts names it that. 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 in runWithTimeouts. Both readings of the outcome race that host expiry: the delivery of the envelope, and the recovery read after a failure. A call into the isolate waits for the isolate to be free and has no bound of its own. Without that race an entry that never reports would keep the caller waiting instead of falling out as a timeout, and so would one that leaves a spinning timer behind.
| 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 into ExecutionTimeoutError’s text |
| Exhausts the memory limit | the engine disposes the isolate | the engine’s Isolate was disposed during execution due to memory limit, rewritten into MemoryLimitError’s text |
Wedges the isolate so no call gets through, 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 that table, and 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 that throws the engine’s words early keeps its own:
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor({ timeout: 2_000 })
console.log( await executor.execute({ code: `async () => { throw new Error('Script execution timed out.') }`, outbound: null, }),)
console.log( await executor.execute({ code: `async () => { for (;;) {} }`, outbound: null, }),)
await executor.dispose(){ result: undefined, error: 'Script execution timed out.', logs: [] }{ result: undefined, error: 'Execution timed out at its 2000 ms bound. Do less work per run and return the part that is already finished.', logs: []}The first call throws Script execution timed out. well inside its 2000 ms bound and gets its own message back. The second spins on for (;;) {} past timeout + grace, and the same engine words come back as ExecutionTimeoutError’s text.
Timers
Section titled “Timers”A bare V8 context has no timer facility, so installTimers publishes one over the host. Both kinds of timer share one identifier space and one cancel set, because clearTimeout and clearInterval are interchangeable on the platform. A callback that is not a function is 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, so a throwing callback 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, and its queueMicrotask is a plain assignment onto a writable global, so it replaces the operation the runtime published. A run reaches the timer module’s version, which schedules the callback with Promise.resolve().then and reports nothing when that callback throws.
delay resolves with the count of delays the registry has expired, this one included. Node keeps one list of timers per duration and appends to it, so two equal delays expire in the order they were set, and the count is that order. The isolate needs it because isolated-vm settles a result: { promise: true } call by one of two routes: inline, when the host promise has settled before the call returns into the isolate, and otherwise through a task queued behind whatever the isolate already has to run. An isolate lagging the host by more than the gap between two expiries receives the later settlement first. The timer script holds a settlement until every lower count has run, so callbacks run in the host’s order however the settlements arrive. Each runs in a microtask of its own, and the ones behind a throwing callback still run.
TimerRegistry keeps the pending host handles of one execution. After cancelAll it never sets a timer again, and delay returns never() instead. The reference the isolate calls also checks isolate.isDisposed after the delay elapses and awaits never() when that is true. 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 ends an invocation the same way, and an asynchronous call a handler neither awaited nor passed to ctx.waitUntil can be cancelled once the invocation ends.
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 the clock counts isolate.wallTime, a pending timer advances none of it, and 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.
Tool calls across the boundary
Section titled “Tool calls across the boundary”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, always as a pre-serialized JSON string. The host reference always resolves 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.
| The call the reference gets | The frame it returns |
|---|---|
| one arriving after the run settled | ['thrown', ...] with EXECUTION_COMPLETED_MESSAGE, the pinned Execution has already completed. |
one whose provider name, tool name and arguments exceed PAYLOAD_LIMIT together |
['thrown', ...] with PayloadTooLargeError’s text for the tool call |
| one on a request that declared no dispatch function | ['returned', ...] with the ToolNotFoundError envelope any unknown tool gets |
| any other call | ['returned', ...] with what the caller’s function returned, or ['thrown', ...] with the text of its failure |
installDispatch tests those in that order. The completion guard lives on the host because completion has to block a late call while the isolate may still be running. The payload bound covers three strings that travel to whoever serves the tool, and for a pooled execution that is another process. Unlike the timers install, this one has no disposal guard, because the reference only ever resolves and isolated-vm drops a resolved settlement aimed at a disposed isolate with no host-side effect. dispatch.spec.ts pins that directly.
On the way back into user code the generated proxy parses the returned JSON through the codec. JSON that fails to parse, or that parses to something other than an object, throws The dispatch answered outside the result envelope. An envelope with an error member is thrown 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.
Modules
Section titled “Modules”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, and 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 does not resolve to a member of 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 |
An entry with both js and cjs resolves as js everywhere, and the require space skips it. Entries with none of the kinds throw UnsupportedModuleKindError, and a name absent from the map throws ModuleNotFoundError. Linking runs inside entry.instantiate, so 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. A bare specifier resolves to a sibling of the importer when the map declares one, and otherwise stays 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, and a referrer the linker never compiled raises an INTERNAL fault, with no fallback to an exact-key match.
compile also reads and writes 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. A stored blob V8 rejects is ignored and the source is parsed in full, and 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. Names the graph has already met come 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 that reach one module would collide, 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 a nested require reaches any sibling, and it includes the require runtime. 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 returns the resolved id or throws. Factories live in a Map because an object literal would route ids like __proto__ or toString through Object.prototype.
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 that two ESM-imported facades both require runs once per facade:
import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor()
console.log( await executor.execute({ code: `import left from './left.js'import right from './right.js'
async () => ({ left, right, runs: globalThis.runs })`, modules: { './shared.js': { cjs: `globalThis.runs = (globalThis.runs ?? 0) + 1module.exports = { run: globalThis.runs }`, }, './left.js': { cjs: `module.exports = require('./shared.js')` }, './right.js': { cjs: `module.exports = require('./shared.js')` }, }, outbound: null, }),)
await executor.dispose(){ result: { left: { run: 1 }, right: { run: 2 }, runs: 2 }, logs: [] }./shared.js runs twice, once inside the ./left.js facade and once inside ./right.js, and runs counts both.
Outbound
Section titled “Outbound”chooseOutbound in fetch.ts turns the tri-state of the outbound setting into one function of the wire request. null becomes a function that rejects with OutboundDisabledError, because the outbound contract promises a rejection and a caller is free to reach it with .catch. A function the caller supplied 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. But the disabled state rejects without reading the request at all. measureRequest counts the URL, every header name and value, and the body bytes, and a request past PAYLOAD_LIMIT throws a PayloadTooLargeError naming the outbound request. That bound applies in native mode too, where nothing crosses a transport, 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 the outcome crosses as ['returned', response] or ['thrown', text], and the runtime’s fetch throws a TypeError with the text of a thrown frame. 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.
Console
Section titled “Console”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, the wire’s formatting rule. The methods the Console Standard declares 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 returns an object whose shape this sandbox would have to invent.
reportException in the runtime ends at console.error when no listener cancelled the error event it fires, so an exception a listener raises inside a dispatch is captured with the run’s other lines. Events with no tree owns the rest of that path.
Every line crosses through applySync, which blocks the isolate until the host push returns. The next sandbox instruction therefore runs only once the host has the line, 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 line with CONSOLE_CAPTURE_MESSAGE states the bound, and the sink returns false. On the isolate side that false 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, which stop as soon as the sink has returned false once. stop() is the other ending: a retained execution has already delivered its lines, so what its invocations write is dropped without a final line, since the run reached no bound.
Handles
Section titled “Handles”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 with 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 with no executionId.
RetainedHandles owns the retained half. retain mints an executionId with randomUUID and 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, because 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, so the dispatch and outbound wraps installed once per isolate pause the clock that owns the run now. performInvocation sets the engine timeout at timeout + grace on that call as the backstop for a synchronous spin, and races the call against clock.expire(timeout). The expiry resolves to undefined while the invoker always resolves to an object, so an undefined answer means the bound expired on a parked run.
Disposal is the only way to stop a wedged isolate, so most failures of an invocation end the retained execution with it.
| What the invocation meets | What the caller reads | The retained execution |
|---|---|---|
| a throw from the handler, with the isolate still live | the thrown message as it reads | survives, with its handles intact |
clock.expire(timeout) winning the race |
ExecutionTimeoutError |
disposed |
an engine text rewriteEngineTermination turns into a timeout or a memory failure |
ExecutionTimeoutError or MemoryLimitError, whichever the rewrite produced |
disposed |
| an isolate that reads disposed with no engine text to rewrite | MemoryLimitError |
disposed |
a closeHandles that arrived while the invocation waited its turn |
HandlesClosedError |
already dropped by that call |
The disposed-isolate row covers a death between invocations. Timers of the execution stay alive with it and the memory limit is the one thing that ends what they do, and the engine words differ when the death happens between calls, so the rewrite alone cannot recognize it.
The handler’s 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 has 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 bridge
Section titled “Crypto bridge”crypto.ts is the host’s half of crypto.subtle. The isolate contains 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 returns 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 kept in a Map, and the isolate receives a description with the number that names it, 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 fails with a TypeError reading 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 looked up in a fixed list of the methods SubtleCrypto declares, never reached on the interface, because the host’s own implementation has more members than the standard declares. A name outside the list fails with 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 run still times out, 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 has bounds of its own. KEY_LIMIT caps how many keys one execution may create, because 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 fails with The execution has ended. instead. One bridge belongs to one isolate, so the keys of an execution leave with that execution and no other can name them.
A failure crosses as a settled frame with 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 covers what the bridge changes for code written against the standard: the cap on keys, and the members of an algorithm object that never cross.