Skip to content
4 min read · 788 words

Isolation Battery

Heavy or untrusted work does not belong on the thread your agent's event loop runs on. In the browser, that means a large model's inference blocking every repaint, or a WebGPU context that outlives its usefulness and can only really be reclaimed by throwing it away. In node, it means a native addon whose segfault takes your whole process — every in-flight request, every open connection — down with it. Both problems have the same shape: move the heavy/untrusted part somewhere it can crash alone, and get a clean, typed way to talk to it. This battery is that substrate.

The thesis

1. Run it off-thread or out-of-process, on purpose. In the browser, a Web Worker keeps the main thread free for repaints and input while the heavy work runs elsewhere, and — because a Worker owns its own WebGPU/wasm state — a bad device loss is recoverable by simply throwing the worker away and spawning a fresh one, rather than trying to same-thread-dispose() a context that may already be wedged. In node, a child_process is a genuinely separate OS process: a native-addon segfault, a V8 fatal error, or an OutOfMemory kill terminates that process and surfaces to the host as an ordinary 'exit'/'error' event — never as the host's own process going down.

2. child_process over worker_threads, deliberately. Both give you a separate V8 isolate and a message-passing channel. They differ in exactly the failure mode this battery exists to contain: worker_threads share the host's address space, so a native crash 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 child process cannot do that to its parent. For untrusted or native-dependency-heavy guest code, that process-level fault boundary is the entire point of choosing node's child_process API, and it is why this battery's node transport imports node:child_process directly rather than worker_threads.

3. Hand-rolled three times already — so it ships once. Before this battery existed, this exact pattern — spawn a guest, hand it a typed request/response surface, recover from a crash — was written from scratch three separate times in this repo: the flagship agent's LiteRT-LM worker pair (litert_lm_worker.ts + litert_lm_worker_proxy.ts, 582 lines together, see the showcase's "Surviving the GPU" section for the device-loss story that pair solves by hand), the media battery's BinaryExecutor seam, and the specialists' createPipeline factory seams. Each of those solved the same problem — spawn, request/response, crash recovery — with its own one-off wire format and its own hand-rolled crash policy. This battery is the one substrate: declare a spec, implement it guest-side, get a typed facade host-side, over either transport.

Quick start

Three pieces: a shared spec (imports from the environment-neutral barrel, safe in either guest or host code), a guest entry point that implements it, and host code that spawns and drives it.

ts
// spec.ts — shared by guest and host, imports nothing environment-specific
import { defineIsolatedService, method } from '@nhtio/adk/batteries/isolation'

export const CounterService = defineIsolatedService({
  name: 'counter',
  methods: {
    increment: method<[amount: number], number>(),
  },
})
ts
// guest.ts — the node child_process entry point (a Web Worker guest looks identical, minus the file split)
import { serveIsolated } from '@nhtio/adk/batteries/isolation'
import { CounterService } from './spec'

let total = 0
serveIsolated(CounterService, () => ({
  increment: (amount) => (total += amount),
}))
ts
// host.ts — fork the guest and call it like a normal async function
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) // 5
await counter.dispose()

That is the whole shape. counter.api.increment is an ordinary Promise-returning function — no manual postMessage/fork wiring, no hand-rolled correlation ids, no bespoke crash handling. Swap forkIsolated for spawnIsolated and the same spec runs against a real browser Worker instead, unchanged.

What this is not

Not a task pool

createIsolatedService/spawnIsolated/forkIsolated return a long-lived, stateful IsolatedService you construct once and call many times — the guest keeps running (and can hold its own in-memory state, like the counter above) between calls. This is not a job-per-call worker pool that spins up a fresh process for every unit of work; if that is what you need, this battery's crash-recovery/recycle machinery is the wrong layer to build it on top of (build a pool of IsolatedService instances instead, each long-lived).

Opt-in, not mandatory

Every other battery in this repo works completely standalone, on the main thread, in the same process. Nothing requires isolation. This battery exists for the specific cases where you want a heavy or untrusted dependency off the main thread or out of the host process — reach for it when that's true, ignore it otherwise.

Where to go next

  • BrowserspawnIsolated, the WorkerResolver bundler seam, transfer() zero-copy, crash → onCrashrecycle(), and the isolateFunction Blob-URL escape hatch.
  • NodeforkIsolated, serialization: 'advanced', the ChildResolver + execa recipe (and its two documented hazards), crash containment, autoRespawn.
  • Recipes — the LiteRT-LM worker pair refit onto this substrate, isolated embeddings over a real adapter unchanged, custom classes crossing the wire, wiring an observability dashboard.
  • Assembly → Isolation batteries — the full option/exception surface, side-by-side with the other batteries domains.
  • Surviving the GPU — the hand-rolled worker pair and crash-escalation ladder this battery generalizes.