The worker pool
The sandbox answers one request and is then finished with it. Nothing in it knows that a second request exists, which process it is running inside, or what to hand back when both arrive at once. Those are the pool’s questions. Its answers are a bounded set of child processes, a rule for choosing one of them, and a replacement policy for the ones that die or wear out. The last answer is a refusal that arrives immediately, not a queue that grows until the whole process is in trouble.
All of it lives in packages/executor/src/pool. Two thin layers sit on top, and neither adds machinery of its own. IsolateExecutor in packages/executor/src/executor/isolate-executor.ts serves the native contract inside the host’s own process. The per-execution bridge in packages/executor/src/server/bridge.ts serves the same contract to a caller over a WebSocket, and that bridge is the next chapter. What a caller may ask for is stated in the executor protocol, what a deployment decides in the operator guide, and what each bound is claimed to hold in the threat model.
Each worker is a forked process and its watcher
Section titled “Each worker is a forked process and its watcher”Each worker is one forked Node process and the object that watches it. Worker in supervisor.ts holds the child, the capnweb session over its IPC channel, the health watch and the last memory reading the child reported. It lives from the first answered heartbeat until the child exits, stops answering or is shut down. Every death outside a shutdown is published as a crash.
Worker.spawn() forks childEntryPath with the execArgv, env and cwd it was given, through child_process.fork. That same call gives parent and child the single IPC channel every exchange below rides on. isolated-vm cannot create an isolate in a process started from the Node startup snapshot, so the pool passes --no-node-snapshot to every child by default. sandbox/isolate.ts states that beside assertNoNodeSnapshot, the guard that settles the question by building a trivial isolate instead of reading execArgv back. Before it waits for anything spawn builds an RpcSession over IpcTransport on the fresh channel and takes the child’s remote main, typed as ChildApiShape.
Then it asks for one heartbeat under an allowance of its own, because booting a process is a different scale of waiting than probing one that is already running. One that never arrives rejects the spawn with WORKER_CRASHED, retryable, under the text Worker <id> failed to start. with the underlying failure as its cause. An answered one marks the worker spawned, starts the health watch, and only then emits spawn with the child’s pid, so a listener that throws cannot leave a live child running unwatched. Liveness itself is one field. alive answers whether the worker still holds the remote stub, and a shutdown and both routes to death clear it.
Startup order in the child
Section titled “Startup order in the child”child-entry.ts acts only when it is the fork’s own entry script. runsAsEntryScript compares import.meta.url against the realpathed process.argv[1], because the ESM loader realpaths the main module and a symlinked entry would otherwise never match. Imported inside a test runner the module stays inert, so no stray session opens in a process that merely happens to load it.
Order decides the rest. new IpcTransport(process) attaches the message listener before anything is awaited, so a frame the parent sends while the module is still evaluating reaches a channel somebody listens on. That transport asserts an open IPC channel at construction and throws INTERNAL without one: a transport over anything else is a wiring bug, not a runtime condition. new ChildApi() then composes one Sandbox carrying a cachedDataStore map, and that sandbox reads the runtime snapshot in its field initializer. So V8 serializes the generated standard library once per child, and every isolate the child later creates is restored from the copy. One sandbox per child lets the compiled-module cache and the snapshot outlive an execution, while each execution still gets an isolate of its own.
Checking the engine starts with the construction of ChildApi, and nothing awaits it there. assertEngineServesSandbox is one of its fields, so its promise is stored, and heartbeat awaits it before answering anything. That puts the verdict exactly where the supervisor decides a worker is live. A child whose isolated-vm build cannot serve this sandbox never answers a first heartbeat, its spawn rejects with WORKER_CRASHED, and the text naming the missing behaviours travels on the chain of causes behind that rejection.
All of that time is spent inside the spawn allowance, not beside it, so the check has to fit. The three probes run together under Promise.all, and answerWithin bounds a whole probe instead of single calls inside it, because creating a context, compiling and reading a result back can each hang. The worst case is therefore one ENGINE_PROBE_TIMEOUT of 5 s, inside the 10 s spawnTimeout defaults to, and a deployment that lowers spawnTimeout under it is back where this started. An engine that hangs on one probe is reported as missing that behaviour and no other. A stock engine returns from the probes immediately and fails on what it returned. That leaves a boot merely slow on a loaded machine, and that spawn reports the unanswered heartbeat. The sandbox chapter says what the check probes, and why it probes behaviour and not options.
How a request finds a worker
Section titled “How a request finds a worker”WorkerPool.execute stamps the arrival time, refuses with INTERNAL and the text The pool is shutting down. when the pool is going down, mints an execution id from a counter, and leases a worker. The wait it measures is the gap between arrival and lease, and that number travels on the execution.start event.
leaseWorker publishes the queue depth before anything else. Every execution announces the depth it found, so a pool that has never queued still carries the series and a fresh replica can be scaled on from its first execution. Then it looks for room:
flowchart TD
request["execute(request)"] --> depth["publish queue.depth"]
depth --> pick{"an alive, undrained worker with capacity"}
pick -->|"the least loaded, earliest on a tie"| lease["count the load, run on it"]
pick -->|"none"| room{"workers + spawning below maxWorkers"}
room -->|"yes"| spawn["spawn, then lease the new worker"]
room -->|"no"| queue{"the queue is below maxQueue"}
queue -->|"yes"| wait["enqueue the lease and wait"]
queue -->|"no"| overflow["reject with QUEUE_OVERFLOW"]
spawn -->|"the first heartbeat is answered"| lease
spawn -->|"no answer within spawnTimeout"| failed["reject with WORKER_CRASHED"]
wait -->|"serveQueued, after a settle, a retirement or a respawn"| lease
Every refusal leaves execute before runOn, so it publishes execution.reject and nothing else. That covers the overflow, a spawn that never answered, and the two failures that can reach a lease already waiting: a shutdown draining the queue, and a replacement spawn that keeps failing.
Load accounting is WorkerLoad in queue.ts. It counts running executions per worker against executionsPerWorker. pickLeastLoaded walks the candidates, skips every worker already at its capacity, and keeps the earliest of equal counts, because the comparison is strict and the caller’s order is therefore the tiebreaker. Candidates are the workers that are alive and carry no drain mark. start raises the count and throws INTERNAL when the worker is already full, so a broken dispatch loop fails at once instead of overloading a child unnoticed.
With no candidate that has room, and workers.size + spawning.size under maxWorkers, the pool spawns a new child. Counting the spawns in flight keeps two concurrent leases from spawning past the bound. shutdown waits on that same set, so a fork landing mid-shutdown cannot orphan its child.
Past both, the caller waits. Each lease is enqueued as a pair of settlers, and its promise stays pending until serveQueued hands it a worker. That runs after every settle, after a retained execution frees its slot, after a retirement and after a crash respawn. It picks a candidate, dequeues one lease, counts the load, publishes the depth and resolves the lease with the worker, looping while the queue holds anything. With no candidate it spawns one where the bound allows and the pool is not shutting down, then stops either way. A successful spawn calls serveQueued again. But a failed one goes to failOneLease, which rejects a single waiting lease and serves again. So a spawn failure that persists drains the queue instead of parking the rest of it forever. That lease takes a typed fault as it is and anything else as WORKER_CRASHED, retryable, with the text A worker failed to spawn.
Queue and its bound
Section titled “Queue and its bound”BoundedQueue in queue.ts is a first-in-first-out list with a hard bound, maxQueue. enqueue past the bound throws QueueOverflowError from the protocol package, and the throw happens inside the promise executor of leaseWorker, so the caller’s own execute rejects with it. A caller reads the code QUEUE_OVERFLOW, retryable true, and the pinned text The queue is at its bound of <maxQueue>. Retry when a running execution settles. Answering at once is the point of the bound: a caller gets something to retry and an operator something to scale on. But a queue that simply grew would give neither.
Whenever a lease fails, the pool publishes execution.reject carrying the code. That keeps work turned away without running apart from work that ran and failed. queueFull answers whether the next enqueue would be refused, reading true while the queue stands at its bound and false again as soon as a dequeue frees room. Shutdown empties the queue from the other end. Every waiting lease is rejected with INTERNAL and the same The pool is shutting down. a fresh execute meets, and the new depth is published once for all of them.
Lifecycle of a worker
Section titled “Lifecycle of a worker”Fresh isolates cover what leaks inside one execution. Anything that outlives one execution accumulates in the child process itself, and retirement is the answer to that. lifecycle.ts states both bounds in one function.
stateDiagram-v2 [*] --> Spawning: fork the child entry Spawning --> Serving: the first heartbeat is answered Spawning --> [*]: no answer within spawnTimeout, the spawn rejects Serving --> Draining: shouldRetire answers executions or memory Draining --> Retired: the running count reaches zero Retired --> [*]: SIGTERM, then SIGKILL after the grace Serving --> Dead: exit, an unanswered heartbeat or a broken session Draining --> Dead: exit, an unanswered heartbeat or a broken session Dead --> [*]: SIGKILL, a replacement is spawned
shouldRetire compares the worker’s execution count against retireAfterExecutions and its memory reading against workerMemoryLimit. It reads the execution count first, so a worker over both bounds retires with the reason executions. Both comparisons include the bound itself. That memory reading is the child’s own process.memoryUsage.rss() from the last heartbeat, compared as it arrives, with one unit on both sides and no scaling anywhere in the module.
retireWhenDue runs on a live worker after every settle, and after a retained execution frees its slot. It raises the pool’s own count for that worker, asks shouldRetire, and marks the drain when the answer is yes. Load settles first, so the verdict already counts the execution that just finished.
A drain is only a mark. WorkerDrain holds the marks, mark records the reason, and the first mark wins: a second cause does not change the fact. isMarked takes the worker out of the lease candidates, so a draining worker takes no new work, and drained answers true once the marked worker’s running count reaches zero. But nothing in flight is cut short for a retirement.
retireWorker drops the worker from the pool’s maps first, publishes worker.retire with the reason, the execution count and the memory reading, then shuts the child down with the ordinary grace. Where the pool is not shutting down and the bound allows, it spawns a replacement, and it serves the queue either way. Replacements that fail to spawn are logged and routed into failOneLease.
Retirement replaces a worker that has served enough. Termination covers the case no bound inside the isolate can reach: a child wedged in native code, or anywhere else the engine’s own timers do not run. runOn schedules one per execution:
this.terminations.set( executionId, Math.min(request.timeout + request.grace + this.terminationDelay, 2 ** 31 - 1), () => { this.logger?.warn({ workerId: worker.id, executionId }, 'terminating a hung worker') worker.kill('SIGKILL') },)terminationDelay is the headroom past the run’s own bounds. That clamp exists because a larger sum reaches setTimeout past the widest delay a 32-bit timer accepts, where Node sets the delay to one millisecond and fires almost at once. Settling cancels the schedule in the finally of runOn, and shutdown cancels every pending one.
Host waits pause a termination. The child announces the edges over the session through reportHostWait, pause freezes the remaining delay, and resume schedules what is left. Those edges are the run clock’s own, described in the sandbox chapter. wrapHostWait opens one before the host sees a tool call or a caller-supplied outbound call and closes it when that call settles, and RunClock announces the outermost edge alone. A request that counts host waits is given a null reporter and keeps the plain wall bound, because the edges have no say there. Without the pause, a wall bound at the timeout plus the grace plus the delay would end exactly the runs the paused clock legitimizes. Timeouts and the run clock states the semantics it follows.
When a child dies
Section titled “When a child dies”Every way a child can die arrives at Worker.fail. The child’s exit event reports child exited with code <code> and signal <signal>. An unanswered heartbeat reports its own timeout text. That heartbeat is the signal that catches a hung child, because a busy loop never exits by itself. A broken session under an in-flight call reports what the transport said, and it lands first, because the session breaks before the exit event arrives.
fail is idempotent and the first reason wins. It clears the remote, stops the health watch, sends SIGKILL so a hung child cannot outlive its worker, and emits crash for a worker that was announced. A child that never came up publishes no crash, because the spawn rejection already reached its caller.
Whoever called the dying execution gets WORKER_CRASHED, retryable, with the text Worker <id> crashed during an execution: <reason>. But two failures take other routes through wrapExecuteFailure. A rejection carrying a typed code other than CONNECTION_BROKEN passes through untouched, because the child answered and lives on. The child’s PROTOCOL_MISMATCH for stub mode without an outbound function is that case, and the worker keeps serving after it. An execution cut by a shutdown reports CONNECTION_BROKEN and the text Worker <id> was shut down during the execution., so the pool does not go retrying against a worker it asked to leave.
handleWorkerCrash covers everyone else. It builds the crash event with the ids of the executions that were running on that worker, and announces the close of every retained execution the child held with the reason crash. Dropping the worker from the maps resets its load and clears its drain mark. Then it shuts the child down with no grace and spawns a replacement, unless the pool is shutting down. Publishing comes last, after the bookkeeping. Other executions sharing that worker meet the same WORKER_CRASHED through their own broken calls, since the session rejects each of them. Queued leases are untouched and wait for the replacement.
Heartbeat
Section titled “Heartbeat”startHealthWatch sets an interval and each tick asks for a heartbeat. requestHeartbeat races that call against a deadline, and both timers are unreferenced so neither holds the parent process open on its own. A heartbeat that loses the race declares the death through fail and rejects with WORKER_CRASHED. The timer swallows that rejection, because the declaration has already happened and the rejection adds no new fact.
Only the child can measure what comes back:
async heartbeat(): Promise<ChildHeartbeat> { await this.engine
return { memory: process.memoryUsage.rss(), running: this.running, executions: this.executions, }}Each worker keeps the memory reading in lastMemory, which worker.memory answers and the retirement bound compares. running counts the executions and handle invocations inside the child right now. executions counts the runs that reached the sandbox. A call refused before any accounting never appears in it, stub mode without a stub among them. The pool keeps its own per-worker execution count in executionCounts, and retirement compares that one instead. Spawning passes no supervision options, so every pooled worker runs on the numbers at the end of this chapter.
IPC transport and the message ceiling
Section titled “IPC transport and the message ceiling”Pool and children speak capnweb over the fork’s IPC channel. IpcTransport implements capnweb’s RpcTransport over an endpoint that is process in the child and the ChildProcess in the parent. A forked pair always shares one channel, so an endpoint without one fails at construction, not at the first send.
A frame is one string. send hands it to endpoint.send with a callback. A channel that closes mid-send raises an error event on the endpoint when nothing takes the callback, and an error event nobody listens for is fatal, so the callback routes that race into the ordinary disconnect path. receive answers the next string, and only strings belong to the session, so anything else on the channel is someone else’s traffic and is ignored. Frames arriving with no receiver waiting are queued, and a disconnect drains the queue before it starts rejecting, so the session reads everything the peer sent before it died. Node’s IPC serialization carries whole strings unchanged, so capnweb frames need no framing of their own on top of it.
The ceiling on one message belongs to capnweb, not to the channel underneath it. DEFAULT_LIMITS.maxMessageSize sets it at 33 554 432 UTF-16 code units. Each session measures an incoming frame against it in its read loop, before the JSON is parsed. A frame over the bound throws out of that loop and takes the whole session down with it. The same bound governs the WebSocket session in front of the server. But underneath, the IPC channel contributes no bound at that scale, and it carries a string comfortably past the ceiling unchanged.
ipc-transport.ts holds the matching bound on the sending side, because the session that would refuse an oversized frame is the peer’s. Left alone, the parent would watch its child’s session die on a TypeError about an incoming message, which reads as a worker dying for no stated reason. So send fails the transport first with a CONNECTION_BROKEN naming the size of the frame it refused. But every payload a guest builds is bounded far under that where it is built, at the PAYLOAD_LIMIT and CONSOLE_CAPTURE_LIMIT of the bound on everything a run sends. A frame that reaches this check is a defect in this repository, and the check exists so the defect names itself. ipc-transport.ts carries its own copy of capnweb’s ceiling as MESSAGE_LIMIT, and no lane holds that copy against DEFAULT_LIMITS, so a change upstream leaves it stale and silent.
One session carries both directions. The parent holds the child’s remote main, and a function passed as a call argument crosses as a live stub the child calls back through. Tool dispatch, the outbound function and the host-wait reporter reach the parent that way. protocol.ts states the rest: the request holds data alone, and the tri-state outbound rides beside it as an explicit mode, so no meaning hangs on how the wire treats an empty slot.
outboundMode: request.outbound === undefined ? 'native' : request.outbound === null ? 'disabled' : 'stub',Live objects cross that session, and capnweb’s reader changes them on the way in. evaluateImpl walks an incoming object and deletes every own key that is in Object.prototype, along with toJSON. Upstream states the reason beside the loop: stopping a peer from overriding an Object.prototype member, __proto__ above all, and stopping a toJSON from snooping on the session’s own JSON.stringify. But neither a raised error nor a log line marks it. { ordinary: 1, constructor: 2, toString: 3, valueOf: 4 } serializes to exactly that JSON and arrives as { ordinary: 1 }.
So this protocol sends no plain object whose keys a caller chose. A run’s result crosses as resultJson, the JSON text the isolate produced, and IsolateExecutor parses it with parseForDispatch. A request’s module map crosses as [name, source] pairs built by listModuleEntries, which also turns a json source into the module source it stands for, and the child rebuilds the map with Object.fromEntries. Tool calls were never exposed, because the dispatch grammar already sends the arguments and the answer as one JSON string each. A program returning { constructor: 1 } gets that object back through Sandbox, through the pool and through the daemon, and a module named toString imports in all three. The deletion is still a defect upstream, and decision 0006 asks for the fix to go there.
Round trip of a tool call
Section titled “Round trip of a tool call”Tool calls leave guest code and reach the caller’s own function across one isolate boundary and one or two process boundaries, and the answer comes back the same way:
sequenceDiagram participant G as guest code participant C as child process participant P as pool parent participant H as the caller G->>C: __dispatch(provider, tool, argsJson) C->>C: measure the three strings against PAYLOAD_LIMIT C->>P: capnweb frame over the IPC channel Note over C,P: the run clock and the termination pause on this edge P->>H: the caller's dispatch, or the bridge's stub over the WebSocket H-->>P: one JSON string carrying the envelope P-->>C: capnweb frame C-->>G: ['returned', json] copied into the isolate
It starts at the generated proxy. proxy-source.ts builds one Proxy per provider whose get trap answers a tool name with an async function, so the call becomes __dispatch(provider, tool, argsJson) with the arguments serialized by the dispatch codec. __dispatch was installed by sandbox/dispatch.ts over an ivm.Reference to a host function in the child, and the install script deletes the handoff global, so user code reaches the capability and never the reference behind it. The isolate half of that seam belongs to the sandbox chapter, and this chapter picks the call up where it leaves the child process.
In the child, the host function is where the run’s own rules apply. It answers the pinned completed text for a call that lands after the execution settled, refuses a call whose provider name, tool name and arguments together pass PAYLOAD_LIMIT, and answers the not-found envelope when the request carried no dispatch at all. But it never rejects: isolated-vm delivers a rejection into the isolate while the host process still records it as unhandled, so a failure travels inside a settled two-element frame that the isolate-side script rethrows.
It calls the stub the parent passed as an argument of execute, wrapped by wrapHostWait, so entering it opens the run’s host wait and the parent’s termination pauses. That stub call crosses the IPC channel as a frame. In the parent, the function behind that stub is the caller’s dispatch wrapped by measureDispatch, which records executor.tool.duration around the round trip.
For IsolateExecutor the journey ends in the host’s own process, one process boundary away from the isolate. For a daemon it goes one further: the parent’s function is forwardDispatch from server/bridge.ts, which calls the caller’s dispatch stub over the WebSocket session. That session and the pool’s IPC session are separate capnweb sessions with independent tables. So a caller’s stub never crosses between them on its own, and the bridge owns the lifetime of the duplicate it holds.
An outbound request follows the same shape through a different host function. Sandbox fetch reaches __sendRequest, installed by sandbox/runtime.ts over a reference, and it calls whatever chooseOutbound picked for the mode. In stub mode that is the parent’s outbound stub, wrapped by wrapHostWait as well, and the request travels as plain data the caller’s own policy sees. In native mode the child performs the request itself through the process’s own fetch, so nothing crosses the pool boundary and the run’s clock keeps counting. The pause leaves that wait out. In disabled mode the rejection is composed inside the child and never leaves it.
Retained executions and their slots
Section titled “Retained executions and their slots”A run that granted handles comes back with an executionId in its result. runOn reads it, records the execution in retained against the worker that served it, and then skips two things it does for every other run. It does not settle the load, and it does not run the retirement check. That worker keeps counting the execution, because the run has settled while the child still holds a live isolate, and only the close puts the slot back into rotation. With one execution per worker, a retained execution occupies a whole worker. So the operator guide tells a deployment that grants handles to count those executions in its worker budget.
invokeHandle finds the entry, raises its running count, clears the idle timer and forwards to the child. But an id the pool does not hold throws HandlesClosedError before any of that, and so does closeHandles. That idle bound watches the gaps between invocations, not a running one, because an invocation whose clock is paused on a caller-served wait can legitimately outlast it. When the count returns to zero, the timer is set again on handlesIdleTimeout. It is unreferenced, so an expiry cannot keep an otherwise finished host process alive.
events.ts names a close reason for each way a retained execution can end. closed is an explicit closeHandles. expired is the idle bound. terminated is an invocation that died at a run bound: the isolate is disposed, because disposal is the only way to stop it. crash is the child dying under it. shutdown is the pool going down, and there the closes are announced before the children die, so whoever holds a resource for a retained execution can release it. The bridge releases the caller’s duplicated outbound stub on exactly that event.
All of them go through releaseRetained, which deletes the entry, clears the idle timer and publishes handles.close. It is idempotent, so racing closers publish one event. A close, an expiry and a termination then call freeRetainedSlot, which settles the load, runs the retirement check and serves the queue. But a crash and a shutdown call none of it, because the worker itself is going and dropWorker resets its load with the rest of its bookkeeping.
terminated arrives differently from the rest. The child answers an invocation with a settled frame, not a rejection. Its parent has to learn whether the failure ended the handles, and only the child can tell a handler’s own throw from a bound that disposed the isolate. A typed fault crosses as its code beside the text, set from an instance the child itself constructed, so a handler throwing the composed closed-handles words does not pass for the fault. Execution handles states the lifecycle a caller sees.
Shutting the pool down
Section titled “Shutting the pool down”pool.shutdown(grace) works in a fixed order, and each step exists because the step after it would otherwise be wrong.
It raises the shutting-down flag, so a fresh execute is refused before it can lease anything. It drains the queue, rejecting every waiting lease, and cancels every pending termination. Every retained execution is then released with the reason shutdown, while the pool is still whole enough for a subscriber to act on the announcement. It waits on the spawns in flight, so a fork that lands mid-shutdown cannot orphan its child. Each worker is shut down with the grace: the remote goes first, then SIGTERM, then SIGKILL when the grace runs out. Then it drops the workers, detaches the instrumentation and removes every listener.
No worker.crash is published for any of those deaths. Worker.shutdown clears the remote before it signals the child, and fail returns at once when the remote is already gone, so the exit event that follows announces nothing.
Events the pool publishes
Section titled “Events the pool publishes”pool.events is a typed EventEmitter<PoolEvents> and events.ts declares the payloads. Every event, every metric derived from one, and every span are tabulated under Telemetry. Who subscribes, and why the wiring reads the way it does, belongs here.
Node’s default cap of ten would start warning under healthy load, because the listener count grows with concurrently retained executions: the bridge holds one close listener per held outbound duplicate. So the pool lifts its own cap to unlimited.
instrumentation.ts is the subscriber the pool attaches to itself. Constructed with instrumented: false it gets silentInstrumentation instead, which builds no instruments and leaves the events published to nobody. Its barrel does not export attachInstrumentation, so a consumer cannot attach a second copy of what the pool owns, and shutdown detaches it before it clears the listeners. Instruments are built on the first event, not at attach time. The metrics API binds its global provider eagerly at getMeter while the tracer stays proxied, and building early would bind a provider the host has not registered yet.
Two measurements come from somewhere other than an event. measureDispatch wraps the caller’s own dispatch function and records executor.tool.duration around the round trip, and runInSpan wraps worker.execute in a pool.execute span carrying the execution id and the worker id, settled by the shared settleSpan. Without a registered SDK the OpenTelemetry API hands out no-op implementations, so both are safe and cheap in a process that measures nothing.
Events serve readers beyond metrics. apps/daemon/src/daemon.ts counts the same facts into the pool statistics its admin app answers with, so codemode-executor status knows how many workers are up and how deep the queue is.
Layers above the pool
Section titled “Layers above the pool”IsolateExecutor turns an ExecuteRequest into a PoolExecuteRequest. A defined request field wins over the construction-time default, an absent timeout falls back to DEFAULT_TIMEOUT, and a null timeout crosses as MAX_SUPPORTED_TIMEOUT, both of them the protocol’s own numbers. Grace and the isolate memory limit come from executor/defaults.ts: the two bounds the pool itself has no opinion about. On the way back it parses resultJson once through parseForDispatch, passes an error envelope and a grant through unparsed, and forwards invokeHandle and closeHandles to the pool unchanged. dispose() is pool.shutdown().
A daemon fills the same request from its own settings through buildServerOptions in apps/daemon/src/config.ts. It reaches these entries of the table below: maxWorkers, executionsPerWorker, maxQueue, retireAfterExecutions, the logger and whether the pool measures anything. The rest stands at the pool’s own number in a daemon deployment, so the operator guide sends a deployment here to read them.
Every default
Section titled “Every default”PoolOptions is all a host may set.
| Option | Default | Declared in |
|---|---|---|
maxWorkers |
availableParallelism() |
pool.ts |
executionsPerWorker |
1 | pool.ts |
maxQueue |
100 | pool.ts |
retireAfterExecutions |
50 | pool.ts |
workerMemoryLimit |
512 MiB | pool.ts |
terminationDelay |
5000 ms | pool.ts |
handlesIdleTimeout |
300000 ms | pool.ts |
childEntryPath |
child-entry.js beside the compiled pool.js |
pool.ts |
childExecArgv |
['--no-node-snapshot'] |
pool.ts, and again in supervisor.ts |
childEnv |
process.env |
pool.ts |
childCwd |
unset, so a child inherits the parent’s directory | pool.ts |
logger |
none, so the pool logs nothing | pool.ts |
instrumented |
attached | pool.ts |
Supervision numbers are not options at all. The pool passes none of them, so every pooled worker runs on the values below, and changing one is a change to the code.
| Fixed | Value | Declared in |
|---|---|---|
| The spawn allowance | 10000 ms | supervisor.ts |
| The heartbeat interval | 5000 ms | supervisor.ts |
| The heartbeat deadline | 2000 ms | supervisor.ts |
| The shutdown grace | 1000 ms | pool.ts, and again in supervisor.ts |
Three numbers a pool request carries belong to the caller and not to the pool: the timeout, the grace and the isolate memory limit. Their defaults live with the layer that fills them in. For a caller reaching the pool across a socket, that layer is the server.