---
url: 'https://adk.nht.io/batteries/isolation/node.md'
description: >-
  forkIsolated, serialization: 'advanced', the ChildResolver + execa recipe (and
  its two documented hazards), crash containment, and autoRespawn.
---

# Node: child processes

## LLM summary — Isolation battery (node)

* `forkIsolated` and `createChildProcessTransport` live at the NODE-ONLY deep import
  `@nhtio/adk/batteries/isolation/child_process` — never the main barrel or the batteries aggregate,
  because this module imports `node:child_process` directly.
* `ForkIsolatedOptions` = `IsolatedServiceOptions & (ForkIsolatedModuleOptions | ForkIsolatedResolverOptions)`
  — exactly one of `{ modulePath, forkOptions? }` (fork a module) or `{ spawn: ChildResolver }` (BYO
  spawner); supplying both, or neither, throws `E_INVALID_ISOLATION_OPTIONS` from a local (subpath-only)
  joi-esque validator (`.xor('modulePath','spawn').with('forkOptions','modulePath')`).
* `serialization: 'advanced'` is PINNED by default (unless `forkOptions.serialization` is set explicitly).
  This matters because `codec.ts` ships `TypedArray`/`ArrayBuffer`/`DataView`/`Date`/`RegExp`/`Map`/`Set`
  as opaque RAW-tier leaves, trusting the transport to carry them faithfully — under node's default
  `'json'` serialization a `Float32Array` argument silently degrades to a plain `{0:...,1:...}` object
  (empirically confirmed losing `instanceof Float32Array`).
* `IsolatedChildLike` is the minimal duck this transport drives (`send?`, `on`, `off?`/`removeListener?`,
  `kill`, `connected?`) — structurally satisfied by BOTH node's own `fork()` result and an execa≥9
  subprocess spawned with `{ ipc: true }` (execa's `Subprocess` keeps the classic `ChildProcess`
  EventEmitter surface alongside its own promise-based extras).
* TWO documented execa/`ChildResolver` hazards (reproduce near-verbatim near the execa recipe):
  1. **Message-buffer steal** — node buffers a child's `message` events until the first listener attaches,
     then flushes once. A `ChildResolver` must NOT attach its own `'message'` listener before returning
     the child — doing so (even just to log) permanently steals the buffered `ready` envelope, and
     `connect()` hangs out the full `readyTimeoutMs`. Applies to execa too (it proxies through an internal
     debounced re-emitter).
  2. **Thenable adoption** — an execa `{ ipc: true }` subprocess is itself a promise (via `mergePromise`)
     that settles REJECTED on non-zero exit or termination signal, including the ordinary `SIGTERM` this
     transport's `terminate()` sends on every `dispose()`/`recycle()`. A `ChildResolver` returning one
     should attach a no-op `.catch(() => {})` to avoid an unhandled-rejection warning on routine teardown.
* The transport module itself avoids the SAME thenable-adoption trap internally: `spawnChild` is
  deliberately not `async`, and boxes the resolver's returned child in `{ child }` before ever crossing a
  `return`/`await` boundary — an `async spawnChild` doing `return options.spawn({spec})` empirically hangs
  until the child EXITS, even with a non-async resolver.
* `child_process` over `worker_threads`: threads share the host's address space (a segfault/V8 fatal error
  can take the whole host down); a real OS child process only kills itself, surfacing as an ordinary
  `'exit'`/`'error'` event. `terminate()` sends `SIGTERM` (or `forkOptions.killSignal`) and does NOT itself
  wait or impose a grace period — `host.ts`'s `dispose()` already owns that via `disposeGraceMs`.
  `recycle()` forks (or re-invokes the resolver for) a brand-new child every time — no child reuse.
* Crash containment: any unexpected `'exit'` (including a non-zero code from a guest's own
  `process.exit(7)`) or `'error'` event fires `onCrash`; an `'exit'` that fires WHILE `terminate()`/
  `recycle()` is already in progress is expected and never reported as a crash. Recover via `.recycle()`
  or `autoRespawn: { policy: createCrashPolicy(...) }`.

## `forkIsolated`

`forkIsolated` lives behind a deliberate deep import, not the main `@nhtio/adk/batteries/isolation` barrel:

```ts
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()`:

```ts
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'`.

::: warning 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:

```ts
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.

::: danger 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](/batteries/isolation/recipes#recipe-observability)) instead of listening on the child directly.
:::

::: danger 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 `await`s 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.

```ts
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`](https://adk.nht.io/api/@nhtio/adk/batteries/functions/createCrashPolicy) sliding window covered in [Browser](./browser#crash-detection-and-recovery)
and [Recipes](./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](./browser) — the Web Worker transport, `transfer()`, and `isolateFunction`.
* [Recipes](./recipes) — the execa recipe in a full working shape, plus the LiteRT-shape refit and isolated
  embeddings recipes.
* [Assembly → Isolation batteries](/assembly/batteries-isolation) — the full option/exception surface.
