bare-supervisor
v0.1.1
Published
Minimal Elixir-light supervisor: the app declares what runs, restart policy, and which deaths reboot what
Readme
bare-supervisor
Minimal Elixir-light supervisor: the app declares what runs, whether each child can be rebooted, and which deaths trigger which reboots.
Installation
npm i bare-supervisorUsage
Declare children, say what each one depends on, and let the tree boot itself in order. This example runs as-is on Node and Bare:
const net = require('net')
const Supervisor = require('bare-supervisor')
const sup = new Supervisor()
sup.add('server', {
async start({ onDeath }) {
const server = net.createServer((socket) => socket.end('hello'))
server.on('error', onDeath)
await new Promise((resolve) => server.listen(0, resolve))
return server
},
stop: (server) => new Promise((resolve) => server.close(resolve))
})
sup.add('client', {
deps: ['server'],
start({ get }) {
return net.connect(get('server').address().port)
},
stop: (socket) => socket.destroy()
})
await sup.ready() // starts server, then client
sup.get('client').on('data', (data) => console.log(data.toString()))
await sup.close() // stops client, then serverstart() returns the child's handle, and that handle is what dependents receive from ctx.get(name). Handles follow the ready-resource convention: if the handle has ready() the supervisor awaits it before the child counts as running, and if the spec has no stop the supervisor calls handle.close(). Handles with neither just skip those steps.
Death is never inferred — only ctx.onDeath reports it, because every teardown signal in the wild also fires on a deliberate close.
Re-construction is the handoff
When server dies, the supervisor stops its dependents in reverse start order, respawns server after backoff, then restarts the dependents. Each dependent's start() runs again, and get('server') hands it the fresh handle:
sup.on('child-died', ({ name, error }) => console.error(name, 'died:', error))
sup.on('child-ready', ({ name, lives }) => console.log(name, 'is on life', lives))
sup.on('gave-up', ({ name, error }) => console.error(name, 'is down for good:', error))There are no proxies and no handle swapping. That is the whole reason the tree stays consistent: nothing caches a handle across a restart, because start() is the only place a handle is ever obtained.
Stow children
bare-supervisor/stow wraps a bare-stow worker as a spec: it launches the bundle, races ready against early exit, reports an exit as death automatically, and stops by destroying the ipc — the child EOF-exits per the stow contract, and stop resolves only after the real exit.
const stowChild = require('bare-supervisor/stow')
const sidecar = require('bare-supervisor/runner/sidecar')
sup.add(
'media',
stowChild(bundlePath, {
runner: sidecar,
args: [storagePath],
backoff: 500,
create: (ipc) => new MediaClient(ipc) // optional; the default handle is the ipc stream
})
)The default handle is the worker's ipc — a duplex carrying the worker's USER frames, so handle.write(...) and handle.on('data', ...) are the wire to it.
Everything not listed here is passed through to the child spec, so deps, restart, maxRestarts, window, backoff and the rest work exactly as they do on a hand-written child:
runner— the spawn primitive, injected (see below). Required.args— argv handed to the bundle (default[]); the worker reads them offBare.argv.create(ipc)— build a richer handle over the wire (optional, may be async). The default handle is the ipc itself. Acreatethat resolves after the child has already exited has its handle closed rather than stranded.logger—{ info, warn }sink for the child's stdout/stderr (defaultnull). The pipes are drained either way, because an unread pipe blocks a chatty child.eager— spawn at spec-creation time instead of atstart(), so the bundle loads alongside the rest of the boot andstart()adopts the warm process (defaultfalse). If the tree tears down beforestart()ever adopts it — an earlier sibling's boot failed, say — the warm worker is EOF'd and reaped, not stranded.killTimeout— ms beforestopescalates a worker that will not unwind toSIGKILL(default1500).
A spec is single-use: it owns one process at a time, so add it to exactly one child.
EOF is the only stop signal a healthy worker needs, but it is not a guarantee: a worker parked in a native call (a model mid-inference) cannot run the handler that acts on it. So when the runner owns the child's pid it also exposes kill, and stop escalates to SIGKILL after killTimeout (default 1500 ms) — otherwise one wedged worker holds its whole tree, and the host process with it, open forever.
killStowChildren()
const { killStowChildren } = require('bare-supervisor/stow')The same escalation for a whole process at once: it SIGKILLs every live stow child this process spawned, for a host on its way out that cannot wait for a clean teardown. bare-supervisor/entry calls it as its own teardown settles, so a process that gave up mid-close still takes its workers with it.
Runners
The runner is injected, never probed at runtime:
bare-supervisor/runner/sidecar— the bundle in its own OS process, for hosts that can spawn.bare-supervisor/runner/relay— for a Bare worklet, which cannot spawn: it asks the host over the stow control sideband, and the host serves it withbare-supervisor/host/spawner.
The same bundle and start(ipc, ready) entry contract run on both.
Nesting
A Supervisor is itself a supervisable handle (it has ready()/close()), so a component ships its own subtree and a host supervises it as one child — deaths recover inside the subtree, and only exhaustion escalates:
sup.add('assistant', {
deps: ['store'],
start({ get, onDeath }) {
const tree = assistantTree({ store: get('store') })
tree.on('gave-up', ({ error }) => onDeath(error))
return tree
},
suspend: (tree) => tree.suspend(),
resume: (tree) => tree.resume()
})bare-supervisor/entry is the same idea across a process boundary: it wraps a tree as a stow worker entry, owning EOF→stop→exit(0) and gave-up→exit(1), so the host declares only its own tree.
Sharing a Corestore
A Corestore directory has exactly one owner. hypercore-storage holds an exclusive lock on it, and a second open fails:
Error: File descriptor could not be lockedSo there is no such thing as a shared corestore — there is one owner, and a way to reach it. Which of the three below you want depends on what the second consumer actually needs.
One process: the store is a child, everyone takes it at construction
const Corestore = require('corestore')
sup.add('store', {
start: () => new Corestore(storagePath),
suspend: (store) => store.suspend(),
resume: (store) => store.resume()
})
sup.add('log', {
deps: ['store'],
async start({ get }) {
const store = get('store').session()
const core = store.get({ name: 'log' })
await core.ready()
return { core, close: () => store.close() }
}
})Two rules make this correct:
- Take a
session(), never the root store. Closing a session closes only the cores made from it, so stopping or reloading a dependent never closes the store out from under its siblings. - Never cache the handle.
start()re-runs on every restart and reload, so a dependent always holds a session of the current store. Reload the owner and every dependent is re-constructed against the fresh one.
suspend/resume on the owner are for the OS backgrounding the whole process. They are not a handoff — see next.
Two processes, one directory: hand it over, don't share it
store.suspend() parks the storage so the same process can resume it. If another process takes the lock in between, resume() fails:
Error: Session ID does not matchThe parked handle is dead — the lock is gone and the session it was holding is stale. So a handoff is a full close(), and coming back is re-construction:
// outgoing: stopping the child closes the store and releases the lock
await sup.close()
// incoming: wait: true blocks on the lock instead of failing the race
sup.add('store', { start: () => new Corestore(dir, { wait: true }) })
await sup.ready()Without wait: true the incoming process races the outgoing one's teardown and throws. With it, it blocks until the directory is free. This is the one case where the two processes must be sequenced by something outside the supervisor — the supervisor guarantees a clean close(), not that nobody else is holding the lock.
Two processes, both needing live cores: replicate
Each process gets its own store, and you connect them over the pipe the supervisor already manages. Replication is its own child — the consumer stays exactly the same as it was above:
const Corestore = require('corestore')
const NoiseSecretStream = require('@hyperswarm/secret-stream')
sup.add('worker', stowChild(bundlePath, { runner: sidecar, args: [workerStoragePath] }))
sup.add('store', { start: () => new Corestore(hostStoragePath) })
sup.add('replication', {
deps: ['store', 'worker'],
start({ get }) {
const stream = new NoiseSecretStream(false, get('worker')) // the worker's ipc
get('store').replicate(stream)
return stream
},
stop: (stream) => stream.destroy()
})
sup.add('log', {
deps: ['store'],
async start({ get }) {
const store = get('store').session()
const core = store.get({ key })
await core.ready()
return { core, close: () => store.close() }
}
})The worker does the mirror image on its side of the pipe, with new NoiseSecretStream(true, ipc).
Note what log does not depend on. It takes a session and a core, exactly as in the single-process case — it has no idea replication exists. Replication is store-wide and peers come and go, so a dropped wire is not a reason to rebuild a consumer. When the worker dies, the supervisor restarts worker and replication; log keeps its session and simply picks the respawned worker up as a new peer.
That split is the point. replication connects a store to a pipe; log holds a session. Fusing them into one child would restart every consumer on every transport blip, and would make the consumer's code differ between the one-process and two-process cases for no reason.
Two things to know about the wire itself:
- The noise wrapper is not optional.
store.replicate()readsstream.noiseStream, so handing it a raw pipe throwsInvalid stream. It is hypercore's wire contract, not a choice — which is why this shape is the fallback, not the default. store.get({ keyPair })keys the core by manifest hash, not by the raw public key. A reader that only has the public key computes the same key with:const key = Hypercore.key({ version: 1, signers: [{ publicKey }] })
See test/corestore.js for all three shapes — the two cross-process ones against real spawned workers.
One swarm, one store
The P2P rule is one Corestore and one Hyperswarm per application. Corestore enforces its half with a lock. Hyperswarm does not — a second swarm just works, badly: another DHT identity, another set of connections, and peers that see two of you. Nothing errors.
So the swarm is a child like anything else, and the tree is what keeps it single:
const Hyperswarm = require('hyperswarm')
sup.add('store', { start: () => new Corestore(storagePath) })
sup.add('swarm', {
deps: ['store'],
async start({ get }) {
return new Hyperswarm({ keyPair: await get('store').createKeyPair('swarm') })
},
stop: (swarm) => swarm.destroy(),
suspend: (swarm) => swarm.suspend(),
resume: (swarm) => swarm.resume()
})
sup.add('swarm-replication', {
deps: ['store', 'swarm'],
start({ get }) {
const store = get('store')
const swarm = get('swarm')
const onconnection = (connection) => store.replicate(connection)
swarm.on('connection', onconnection)
return { close: () => swarm.off('connection', onconnection) }
}
})
sup.add('announce', {
deps: ['swarm', 'log'],
async start({ get }) {
const discovery = get('swarm').join(get('log').core.discoveryKey, { server: true })
await discovery.flushed()
return { close: () => discovery.destroy() }
}
})swarm derives its identity from the store, so the app has one network keypair for the same reason it has one primary key. Teardown falls out of the dep graph: announce leaves its topic, swarm-replication detaches, swarm destroys, and only then does store close — no connection is ever feeding a store that is shutting down.
Note that swarm-replication is the same child as the worker one from the previous section, with a different source of connections. That is the shape worth copying: connecting a store to a source of connections is one job, and it is never the consumer's job.
Worker processes are not peers
Both singletons live in one process, so a worker process is not on the network — it replicates to the host over its pipe, and the host is the only peer. One store, one swarm, N workers:
worker process ──pipe──▶ host ──swarm──▶ networkReplication is transitive, so this works in both directions without any relaying logic. Two things follow from that, and both bite in practice.
The host is a replica, not a relay. It can only serve blocks it actually holds, and seeing a core's new length is not the same as having its blocks. A host that passes data on in either direction has to download it — for an inbound core that means a live range, held open by its own child:
sup.add('backfill', {
deps: ['inbound', 'subscribe'],
start({ get }) {
const download = get('inbound').core.download({ start: 0, end: -1 })
return { close: () => download.destroy() }
}
})Skip that and the far side connects, discovers the core, asks for a block the host never fetched, and waits.
Never snapshot a length across two hops. A worker that does await core.update({ wait: true }) and then downloads core.length blocks is reading whatever the host happened to know at that instant — which, while the host is still pulling from the network, is usually nothing. It completes immediately and reports success having transferred zero blocks. Ask for the range and let it wait for the data:
await core.download({ start: 0, end: expected }).done()The same applies to asserting on the far end: update() resolves once it has synced with a peer, and does not guarantee a peer has connected at all. get() is the call that blocks until the data is really there.
Suspending a P2P tree
Give store and swarm their suspend/resume hooks and the fan-out order is already right, because it is just the dep graph run backwards and forwards:
suspend → announce · log · worker-replication · worker · swarm-replication · swarm · store
resume → store · swarm · swarm-replication · worker · worker-replication · log · announceNetworking parks before storage, and storage comes back before networking. You never park a store that a live connection is still feeding, and never hand the swarm a store that has not resumed.
Workers are not suspended by this — they are separate processes, and a backgrounded app's workers keep producing. What parks is the app's own storage and network. A reload issued while suspended restarts the child but leaves its dependents 'stopped'; resume() reconciles them, and only then does the backlog flush through.
See test/swarm.js for the whole tree against a real DHT testnet: a block written inside the worker process reaching an unrelated peer through the host, the same path surviving a full suspend/resume cycle, and a 1000-block backlog travelling the other way — network to host to worker process — with the worker reporting the block and byte counts it actually holds.
API
const sup = new Supervisor([options])
Options: stallTimeout (ms, default 5000) — a child start() or stop() exceeding it emits 'stall'; the supervisor keeps waiting. Bounding a teardown is the child spec's job (see killTimeout for stow children) — the supervisor has no handle to escalate on.
sup.add(name, spec)
Declare a child. Call before ready(). spec may also be a factory () => spec, which reload re-mints from. Spec fields:
start(ctx)— build the child, return its handle. If the handle hasready()it is awaited.ctx.get(name)resolves a declared dep's current handle.ctx.onDeath([error])reports this incarnation's death; calls from a stale incarnation or during deliberate teardown are ignored.stop(handle)— release the child (awaited; optional — defaults tohandle.close()when present).presuspend(handle)/suspend(handle)/wakeup(handle)/resume(handle)— opt-in lifecycle hooks (optional).inspect(handle)— synchronous state slice forsup.inspect()(optional).deps— names this child needs; boot order is topological, teardown is reverse start order, and a dep's death stops and later restarts this child.restart—'always'(default) or'never'.maxRestarts/window— restart intensity: more thanmaxRestartsdeaths (default3) withinwindowms (default30000) gives up: the child goes'failed', its dependents stay'stopped','gave-up'fires, and the supervisor stays up.backoff— base restart delay in ms (default1000), doubling per death insidewindowand capped bymaxBackoff(default30000).
await sup.ready()
Boots all children in dependency order. A start() throw during boot fails ready() and unwinds the children that did start — there are no restarts during boot.
await sup.close()
Stops all children in reverse start order. Idempotent, single-flight.
sup.get(name)
Current handle of a running child. Throws if the child isn't running.
await sup.presuspend() / await sup.suspend() / await sup.wakeup() / await sup.resume()
Ordered fan-out, nothing more. presuspend and suspend run in reverse start order; wakeup and resume run in start order. A wakeup reactivates a suspended tree for a short work window; children without wakeup fall back to resume. Restarts park during suspend and revive on wakeup or resume.
There is no linger and no coalescing here. The suspend state machine — target vs actual, the grace window, the resume that cancels a pending suspend — belongs to suspendify, which drives these four methods. A child guards its own hooks against repeats, so this supervisor never tracks whether one already ran.
Each transition emits 'power' twice — { phase: 'start' } and { phase: 'done', ms }. A teardown that wedges never reaches the second one, which is how you find it.
await sup.reload(name[, spec])
Deliberate upgrade: stops the child's dependents (reverse order) and the child, swaps in spec — or a fresh one from the child's spec factory — then restarts everything. Not a death: no intensity accounting, and the new code gets a fresh budget. It is also the manual recovery path after 'gave-up'.
With a stow child this is hot code reload: replace the .bundle on disk and reload(name) — the respawned process runs the new code. A reload must not change the child's deps.
Under suspension a reload restarts the child but leaves its dependents 'stopped' — resume() (or wakeup()) reconciles them.
The Erlang mapping: code_change ≈ re-construction against durable storage (start fresh, re-open state), appup ≈ replace bundle + reload(child), relup ≈ restart the whole app.
sup.inspect()
Live snapshot: [{ name, state, lives, deps, error, uptime, deaths, info }].
state—idle | starting | running | stopping | stopped | faileduptime— ms since the current incarnation started,nullwhen downdeaths— recent deaths inside the intensity windowinfo— whatever the spec's optionalinspect(handle)returns for a running child
Safe to call before ready(), where every child reads idle.
Events
'child-ready' { name, lives } · 'child-died' { name, error } · 'child-restarting' { name, delay } · 'child-stopped' { name } · 'child-reloaded' { name } · 'gave-up' { name, error } · 'power' { transition, phase, ms } · 'stall' { name }
License
Apache-2.0
