Skip to content

Process supervision

@warlock.js/process supervises application-owned CLI workers and terminals. Each launch gets a random environment token and a registry entry containing its PID and OS start time, with an isolated process group on POSIX. Command text is for display; it never selects a process to stop.

Terminal window
npx warlock add process

The command pins the package to the installed Core version, creates src/config/process.ts and src/app/processes/{index,lifecycle,devtools}.ts, and imports the lifecycle module from src/app/main.ts. Repeating the command preserves existing files and adds no duplicate import. --no-install and --dry-run --json work through the usual generator interface.

The generated config satisfies SupervisorOptions: owner, registry, and defaults (a five-minute timeout and 512 MiB memory cap). Change these for your workload. Use a unique owner and registry per application instance; workers must not share a registry. The generated startup validator awaits recover() before serving requests and aborts boot on failure. The shutdown hook awaits stopAll() before connectors close. If you start children during startup, await recovery first.

When devtools is installed, its Processes tab discovers the generated devtools.ts registration in development. It calls list() and exposes no stop endpoint. Production does not import devtools. Custom supervisor setups can export processPanel = { list: () => processes.list() } from that file.

import { supervisor } from "@warlock.js/process";
const processes = supervisor({
owner: "example-app",
registry: "storage/processes/registry.json",
defaults: { timeoutMs: 30_000, memoryBytes: 512 * 1024 * 1024 },
});
await processes.recover();
const worker = await processes.spawn({
kind: "worker",
command: process.execPath,
args: ["-e", 'console.log("ready")'],
env: { WORKER_MODE: "once" },
labels: { room: "demo", member: "one" },
});
worker.stdout.pipe(process.stdout);
worker.stderr.pipe(process.stderr);
const result = await worker.exited;
console.log(result.reason, result.code, result.peakMemoryBytes);
await processes.stopAll();

spawn() is asynchronous. On Linux a shell applies inherited resource limits and stops itself; the supervisor verifies its token, PID, start time and group, attempts cgroup attachment, and persists the registry before resuming user code. On macOS and Windows a small Node guardian gates launch until persistence and stays alive for the workload. Its PID is the handle/registry PID. macOS applies inherited shell rlimits before starting the guardian. The command and arguments are passed as an argument array, without interpolation. cwd and env are optional; environment entries override the parent except for the ownership token. Consume stdout/stderr to avoid pipe backpressure. exited reports leader exit; pipe streams can finish later, so await their end events too when collecting complete output.

The launch input uses kind as a workload category; list() exposes it as type. It has no role in ownership or process selection.

APIBehavior
supervisor({ owner, registry, defaults?, pollIntervalMs?, stopGraceMs?, cgroupRoot? })Creates a lazy supervisor; first operation opens the registry. Default poll interval is 100 ms; stop grace is 1000 ms.
await spawn({ kind, command, args?, cwd?, env?, labels?, limits? })Returns { pid, stdin, stdout, stderr, exited, stop() }. Per-launch limits override defaults.
await handle.exitedResolves to { reason, code, signal, peakMemoryBytes }. Reasons: exit, timeout, memory-cap, stopped. Registry persistence or supervision observation errors reject it; a rejected promise does not prove the child exited.
await handle.stop()Stops its verified POSIX group or Windows PID tree; repeated calls after exit are harmless.
await stop({ labels })Stops entries containing every requested label/value. stop() selects all entries.
await stopPid(pid)Only accepts a verified registry entry; otherwise throws ProcessNotOwnedError.
await list({ labels })Returns process metadata, limits, enforcement and peak memory; never environment tokens.
await recover({ keep? })Clears mismatched/dead identities, adopts verified entries selected by keep, and stops other verified entries. Returns PID arrays { adopted, stopped, cleared }. Default: stop verified orphans.
await stopAll()Stops verified entries and releases the registry writer lease. Idempotent; closes the supervisor permanently.
await spawnPty(options)Optional terminal launch; adds write(text) and resize(columns, rows).

Adopted processes cannot regain their original stdio pipes or exit codes. Their original deadline still applies. Recovery requires matching owner, token, PID, start time and POSIX group; it never claims a process by its command. Calling recovery again leaves already tracked children alone. If a launch was persisted at its setup gate, adopting it resumes that gate. There is a small crash window between OS creation and the first registry write: an unregistered stopped child cannot be discovered by recovery. Use the service manager’s containment for whole-app crash cleanup when required.

The registry uses private files (0600 on POSIX; restrict the directory ACL on Windows), a private directory when newly created, an exclusive writer lease and atomic JSON replacement. Preserve its directory across restarts. A stale lease is reclaimed only after its writer’s PID/start time no longer matches. Corrupt registries, incomplete leases and interrupted lease reclamation require manual inspection; they do not trigger process kills. Do not place registries in untrusted or shared writable directories.

PlatformIdentityEnforcement
Linux/proc start ticks + boot ID + environment tokenCgroup v2 → inherited rlimit/RSS fallback. Delegated memory.max caps the subtree; memory.peak reports kernel peaks when available; cleanup uses cgroup.kill where permitted.
macOSps start time (LC_ALL=C TZ=UTC) + guardian environment tokenRLIMIT_CPU/RLIMIT_DATA plus tree RSS polling (rlimit-polling). Softer than Linux cgroups.
WindowsPowerShell/CIM creation time + guardian environment tokenPID-only taskkill /PID <pid> /T /F tree stops; tree working-set polling (rss-polling). Softer than Linux cgroups.

Linux without delegation reports enforcement: "rlimit-polling". Inherited CPU/address-space limits apply; Linux ignores RLIMIT_RSS, so the resident-memory cap is leader RSS polling only. macOS and Windows include the guardian and observed descendants in memory samples; shared pages can be counted more than once. Windows always forces the verified PID tree immediately; stopGraceMs applies to POSIX signals only. PowerShell/CIM must be available on Windows; observation failures surface to the caller and prevent killing. Job Objects are a future improvement for hard Windows containment and memory caps, and are not required for this release. Other OSes throw the exported UnsupportedProcessPlatformError before launching.

The Linux parent must already be delegated to the application UID, with the memory controller enabled by its service manager. The supervisor creates a child directory and writes cgroup.procs; it neither requests root privileges nor changes controllers or moves the application. cgroupRoot may point to an existing delegated directory. Read-only mounts, missing controllers, disabled cgroup v2 or denied writes automatically select the fallback. Inspect each entry’s enforcement rather than assuming a hard memory boundary.

For a non-root UID, delegation must grant write access to the parent directory, the destination cgroup.procs, and the common ancestor’s cgroup.procs for migration. The memory controller also requires an empty parent with memory already enabled in cgroup.subtree_control. Arrange the app in a separate leaf, then set cgroupRoot to its delegated parent so worker cgroups become siblings of the app leaf. Discovery of the app’s own populated leaf often falls back, because the no-internal-process rule prevents enabling memory for its children. Once the manager prepares this layout, creating a directory and writing the gated child’s PID to its cgroup.procs needs no root access. The supervisor performs those writes only inside the delegated hierarchy.

Limits are positive safe integers: timeoutMs, memoryBytes, cpuSeconds (POSIX CPU seconds; rejected on Windows), and Linux-only virtualMemoryBytes (RLIMIT_AS). Address space differs from RSS; V8 reserves substantial address space, so set that limit only when suitable for the executable. Allocation failures from rlimits can produce an ordinary exit; memory-cap means an observed RSS violation or a cgroup OOM kill. CPU-limit exits also use exit and carry the OS signal when available.

Polling may miss short peaks and overshoot the cap until the next interval. Linux fallback accounting covers the leader; macOS/Windows sample the tree, including guardian overhead. Commands and IPC add sampling latency, especially on Windows; pollIntervalMs is a minimum interval and slow samples do not overlap. Descendants that stay in the launch group receive stop signals. Without cgroups, a process that creates another session can escape; if the leader exits during graceful stop, the supervisor cannot safely re-verify it for escalation and will not signal an unverified group. Prefer delegated cgroups for process trees. When a cgroup exists, remaining members are killed when the leader finishes.

Linux start identity includes the boot ID and start ticks. macOS reads headerless ps metadata with LC_ALL=C TZ=UTC: Apple’s ps(1) defines lstart as strftime %c; Apple’s C locale expands this to %a %b %e %H:%M:%S %Y, for example Mon Oct 19 12:34:56 2026. The parser validates it and records UTC epoch seconds, so macOS start-time precision is one second. Windows reads Get-CimInstance Win32_Process creation times, serialized as UTC DateTime ticks in strings to preserve precision; tree snapshots use ParentProcessId and WorkingSetSize (bytes), excluding children older than their apparent parent.

Neither platform needs a compiled identity helper. Their live Node guardian reads the random token from its own environment and proves it with an HMAC challenge through a local Unix socket (0600) or Windows named pipe; the token itself never crosses that connection. This avoids treating command-line text as an environment token. The supervisor reads OS start identity before and after that proof, including during recovery. Ordinary writer leases need only PID/start time and do not require a guardian. Preserve the registry and restrict access to it; tokens remain private. Stale socket files from abrupt macOS kills are replaced only by a new guardian with that same UID/PID.

Identity observation failures prevent signalling. The portable APIs do not provide atomic identity-and-kill: PID reuse can race a signal or taskkill, and Windows process-tree discovery is best effort. If a guardian exits, its remaining descendants cannot be safely selected later through the old PID. Processes that escape containment may survive. This is an ownership guard for cooperative applications, not a hostile-code sandbox.

Install the optional node-pty peer separately. It is imported only by spawnPty(), so ordinary workers need no native dependency.

import { supervisor } from "@warlock.js/process";
const processes = supervisor({ owner: "terminals", registry: "storage/terminals.json" });
await processes.recover();
const terminal = await processes.spawnPty({
kind: "terminal",
command: "/bin/sh",
args: [],
columns: 100,
rows: 30,
labels: { room: "demo" },
});
terminal.stdout.pipe(process.stdout);
terminal.write("printf 'hello\\n'\n");
terminal.resize(120, 40);
await terminal.stop();
await processes.stopAll();

PTY stdout combines terminal output; stderr is an empty stream. PTY signal metadata follows node-pty and may be numeric text. PTY limits and ownership use the same launch gate and registry as ordinary children. Windows terminals use node-pty/ConPTY when installed; set command: "powershell.exe" and write commands with terminal.write("Write-Output hello\r") there.

Implementation references checked on 2026-10-19: Linux cgroup v2, proc start time, initial environment, Linux resource limits, Node detached children, Apple ps man page, Apple ps timestamp implementation, Apple C locale timestamp format, Apple resource limits, shell limit flags and units, Windows CIM process properties, PowerShell Get-CimInstance, Windows taskkill, Node IPC sockets and pipes, Windows Job Objects, and node-pty typings.

The package’s ordinary process specs use children created by those specs and require no delegated cgroup or native PTY. A coordinator can opt into cgroup coverage with WARLOCK_PROCESS_CGROUP_ROOT set to a delegated directory, and PTY coverage with WARLOCK_PROCESS_PTY_TESTS=1 after installing a working node-pty native binary. macOS and Windows command construction, timestamp parsing, PID-tree memory accounting and ownership observations also have injected unit specs that run on Linux without executing foreign-platform tools. No service port is used by these specs.

Configuration validation and guardian credentials

Section titled “Configuration validation and guardian credentials”

supervisor() requires nonempty string owner and registry options. Missing, null or invalid configuration throws TypeError immediately, naming the first invalid option (owner before registry). Validate deployment configuration before starting work:

import { supervisor } from "@warlock.js/process";
const processes = supervisor({
owner: "test-workers",
registry: "storage/test-workers.json",
});

On macOS and Windows, only the guardian receives WARLOCK_PROCESS_TOKEN and WARLOCK_PROCESS_LAUNCH. It authenticates ownership over its socket/named pipe; both private variables are removed before spawning the workload (case-insensitively on Windows). Ordinary environment entries reach the workload. Linux continues to forward the token because ownership is checked through /proc/<pid>/environ.