Node: child processes
forkIsolated
forkIsolated lives behind a deliberate deep import, not the main @nhtio/adk/batteries/isolation barrel:
import { forkIsolated } from '@nhtio/adk/batteries/isolation/child_process'
import { CounterService } from './spec'
const counter = forkIsolated(CounterService, { modulePath: './guest.js' })
const total = await counter.api.increment(5)
await counter.dispose()This module imports node:child_process directly, which is unsafe to pull into a browser-loadable entry point — so it is not re-exported from the main barrel or the batteries aggregate at all. It is reachable only via @nhtio/adk/batteries/isolation/child_process, and only ever runs under node.
modulePath and forkOptions
The common case forks a module path directly, exactly like node's own child_process.fork():
forkIsolated(CounterService, {
modulePath: './guest.js',
forkOptions: { env: { ...process.env, LOG_LEVEL: 'debug' } },
})modulePath must point at something node can load directly (already transpiled/bundled if it isn't plain CommonJS/ESM — fork() cannot load a .ts file itself). forkOptions is forwarded to child_process.fork verbatim, with one default layered on top.
Why serialization: 'advanced' is pinned
Unless forkOptions.serialization is set explicitly, forkIsolated pins serialization: 'advanced' — V8's structured-clone algorithm over the IPC channel, instead of node's default 'json'.
Forcing serialization: 'json' degrades exotic values silently
codec.ts treats TypedArray/ArrayBuffer/DataView/Date/RegExp/Map/Set as opaque leaves that ship at the raw codec tier — zero cost, but only faithful if the underlying transport actually preserves them. Under node's default JSON-style IPC serialization, a Float32Array argument silently degrades to a plain {0: ..., 1: ...} object on arrival — instanceof Float32Array is lost with no error raised anywhere. If you override forkOptions: { serialization: 'json' } yourself, raw-tier fidelity for those exotic containers is what you're giving up; the codec's escalation to the encoded tier (functions, Error instances, custom-encodables) still round-trips correctly regardless, since that path already pays the @nhtio/encoder encode/decode cost independent of the IPC transport's own serialization mode.
ChildResolver — BYO spawner
Pass { spawn } instead of { modulePath } to spawn the guest however you like — your own process manager, custom stdio wiring, a sandboxing wrapper, or a spawning library such as execa:
import { forkIsolated, type ChildResolver } from '@nhtio/adk/batteries/isolation/child_process'
import { execa } from 'execa'
import { CounterService } from './spec'
const spawn: ChildResolver = ({ spec }) => {
const subprocess = execa('node', ['./guest.js'], {
ipc: true,
serialization: 'advanced',
})
// Hazard 2 (thenable adoption) — see below.
subprocess.catch(() => {})
return subprocess
}
const counter = forkIsolated(CounterService, { spawn }){ modulePath, forkOptions } and { spawn } are mutually exclusive — supplying both, or neither, throws E_INVALID_ISOLATION_OPTIONS before anything is spawned. The resolver is invoked once per connect(), including every recycle() — there is no child reuse across a respawn, by either spawn form.
The returned value only needs to satisfy IsolatedChildLike: send?(), on(), off?()/removeListener?(), kill(), connected?. Node's own fork() result and an execa≥9 { ipc: true } subprocess both satisfy it without an adapter — execa's Subprocess type keeps the classic ChildProcess EventEmitter surface alongside its own promise-based sendMessage()/getEachMessage() extras, and this transport only ever drives the classic surface.
Two hazards, verbatim
Both of these are real, observed failure modes — not theoretical caveats.
Hazard 1 — do not attach a 'message' listener in your resolver
Node buffers a child's message/IPC events until the FIRST listener attaches, then flushes the whole buffer to that one listener exactly once. If a ChildResolver attaches its own 'message' listener before returning the child — even just to log traffic — it permanently steals the buffered ready envelope from connect()'s own listener, which then waits out the full readyTimeoutMs for a ready that already came and went and silently rejects with E_ISOLATION_READY_TIMEOUT. execa's Subprocess additionally proxies raw child events through its own internal debounced message re-emitter (lib/ipc/forward.js), so this hazard applies to execa subprocesses too — empirically confirmed by instrumenting a resolver with a diagnostic subprocess.on('message', ...) and observing the resulting connection hang. If you need to observe messages yourself, use IsolationObservabilityHooks' onWire hook (see Recipes) instead of listening on the child directly.
Hazard 2 — attach a no-op .catch() to an execa subprocess
execa's subprocess is ITSELF a promise (via mergePromise, so execa(...)'s return value can double as "the child" and "a promise for its exit result"). That promise settles REJECTED on a non-zero exit or a termination signal — including the ordinary SIGTERM this transport's terminate() sends on every dispose()/recycle(). Since this transport only ever drives the classic ChildProcess EventEmitter surface and never .then()s or awaits the child itself, nothing else ever attaches a rejection handler to it — without your own subprocess.catch(() => {}), a perfectly routine terminate() reports as an unhandled promise rejection.
The transport module guards against the same thenable-adoption mechanism internally: its own spawnChild helper is deliberately not an async function and boxes the resolver's returned child inside a fresh { child } wrapper before it ever crosses a return/await boundary. An async spawnChild doing return options.spawn({ spec }) was empirically found to hang until the child process EXITS — even when the resolver itself is a plain, non-async function — because the language's thenable-adoption rule kicks in at the return, adopting the resolver's thenable child as if it were the async function's own result promise.
Why child_process over worker_threads
worker_threads share the host process's address space. A native-addon segfault or a V8 fatal error inside one can bring the entire host process down with it — there is no "just terminate the worker" once the underlying process has already died. A real OS child process cannot do that to its parent: a segfault, an OOM kill, or an uncaught fatal error inside the child terminates only the child, surfacing to the host as an ordinary 'exit'/'error' event on the ChildProcess object. For untrusted or native-dependency-heavy guest code, that process-level fault boundary is the entire reason this transport is built on node:child_process rather than worker_threads.
Crash containment and recovery
Any unexpected 'exit' — including a non-zero exit code from the guest calling process.exit(7) itself — or an 'error' event on the child fires onCrash. An 'exit' that fires while terminate()/recycle() is already in progress is expected teardown, not a crash, and is never reported as one.
counter.onCrash((info) => {
console.error(`counter guest crashed: ${info.reason} (code=${info.code}, signal=${info.signal})`)
})Recovery is manual (await counter.recycle() — forks a brand-new child through the same modulePath/ forkOptions or ChildResolver; there is no child reuse across a recycle) or automatic via autoRespawn: { policy }, using the same createCrashPolicy sliding window covered in Browser and Recipes.
terminate() itself sends SIGTERM (or forkOptions.killSignal) and does not wait for the child to exit or impose any grace period of its own — createIsolatedService's dispose() already owns that: it sends a shutdown envelope, waits disposeGraceMs for the guest to exit voluntarily, then calls transport.terminate() unconditionally regardless of whether the guest exited in time.
Where to go next
- Browser — the Web Worker transport,
transfer(), andisolateFunction. - Recipes — the execa recipe in a full working shape, plus the LiteRT-shape refit and isolated embeddings recipes.
- Assembly → Isolation batteries — the full option/exception surface.