process
js/process.ts
fino:process - process information and child process spawning.
This module combines two concerns: static process metadata (pid, cwd, argv,
env, etc.) and the Process class for spawning child processes with piped
stdio. The static metadata comes from internal:process, which is a Rust
synthetic module injected at compile time with values that would be awkward
to retrieve from JS (e.g. execPath needs the Rust binary's own path, and
env needs to snapshot the environ at startup).
The child-process APIs are POSIX-oriented. They use posix_spawnp(3),
pipe(2), kill(2), and waitpid(2) semantics, with macOS and Linux
event-loop integrations for process-exit notification.
This is not Node's global process object or child_process API: stdio is
always three parent-managed pipes, env replaces rather than merges, and
there is no shell option, detached child mode, IPC channel, uid/gid switching,
Node event-emitter process lifecycle, or Windows behavior contract.
Why posix_spawn?
execve(2) replaces the current process image, so spawning a different
program while keeping Fino alive requires a primitive that creates a child
process and then execs it. posix_spawnp(3) provides that operation while
keeping the child-side fd setup inside libc file actions.
Pipe lifecycle
Three pipe(2) calls create six file descriptors before spawning:
stdin: [stdinR -> child stdin, stdinW -> parent Writer stdout: [stdoutR -> parent Reader, stdoutW -> child stdout stderr: [stderrR -> parent Reader, stderrW -> child stderr
posix_spawn_file_actions_* wires the child-side fds before exec. The
parent-side fds are set to O_NONBLOCK so they can be used with the event
loop.
buildCStringArray and GC lifetime
posix_spawnp(3) takes a char** argv and a char** envp. We build these from
JS strings by encoding each string to a null-terminated UTF-8 buffer and
placing pointers to those buffers into a pointer array. The tricky part is
GC lifetime: if the individual string buffers (bufs) are collected before
posix_spawnp copies them, the pointer array will contain dangling pointers. To prevent
this, buildCStringArray returns both the pointer array and the bufs
array; callers keep bufs as a local variable so it remains in scope (and
therefore kept alive by the GC) through the spawn call.
Waiting for the child process
Process.wait() uses kernel-native mechanisms to avoid polling:
- macOS:
loop.proc(lp, pid)registers an EVFILT_PROC kevent. kqueue delivers a NOTE_EXIT event the instant the child changes state, with zero CPU overhead while the parent waits for exit.
- Linux:
pidfd_open(2)(syscall 434) returns a file descriptor that becomes readable when the child exits. We poll it withloop.readable()just like any other fd, then close the pidfd. This is the modern alternative towaitpid(2)polling loops.
In both cases, after the kernel signals exit, a single waitpid(pid, 0) is
called to reap the zombie and retrieve the exit status. The status integer
is decoded using the POSIX WIFEXITED / WIFSIGNALED macros inlined as bit
operations.
Exit status decoding
waitpid fills an int status word with the following encoding:
- bits [6:0 = 0x00 -> exited normally; exit code is bits [15:8
- bits [6:0 != 0x00 and != 0x7f -> killed by signal; signal is bits [6:0
- bits [6:0 = 0x7f -> stopped (WIFSTOPPED) - we ignore this case
import { pid, cwd, argv } from 'fino:process';
console.log(`PID ${pid}, CWD ${cwd()}, args: ${argv.join(' ')}`);
import { Process } from 'fino:process';
const proc = new Process('/bin/echo', ['hello world']);
for await (const chunk of proc.stdout) {
console.log(new TextDecoder().decode(chunk));
}
const { code } = await proc.wait();
Interfaces
interface ProcessOptions {
Options for spawning a child process.
Only cwd and env are supported. Node-style options such as stdio,
shell, detached, ipc, uid, and gid are not part of this API.
import { Process, type ProcessOptions } from 'fino:process';
const opts: ProcessOptions = { cwd: '/tmp', env: { PATH: '/usr/bin' } };
const proc = new Process('/usr/bin/env', [], opts);
Properties
cwd?: string
Working directory for the child process.
When omitted, the child inherits the parent's current working directory. If the directory cannot be entered during spawn setup, construction throws before a child process is returned.
import type { ProcessOptions } from 'fino:process';
const opts: ProcessOptions = { cwd: '/srv/app' };
env?: Record<string, string>
Environment passed to the spawned process.
When omitted, the runtime startup environment snapshot is used. Supplying this object replaces, rather than merges with, the inherited environment.
import type { ProcessOptions } from 'fino:process';
const opts: ProcessOptions = { env: { PATH: '/usr/bin', NODE_ENV: 'test' } };
sandbox?: ProcessSandboxOptions
Optional sandbox request for this child process.
The sandbox API is capability-reported. strict mode requests an
OS-enforced security boundary installed before the child runs and fails
closed when the platform cannot enforce every requested category.
bestEffort mode may spawn the child without enforcing requested policy
categories, but the returned Process.sandboxReport records exactly what
happened.
import { Process, type ProcessOptions } from 'fino:process';
const opts: ProcessOptions = {
sandbox: {
mode: 'bestEffort',
resources: { memoryBytes: 64 * 1024 * 1024 }
}
};
const proc = new Process('/bin/echo', ['hello'], opts);
console.log(proc.sandboxReport.enforced.length);
interface ProcessSandboxOptions {
Sandbox options for a child process.
This type describes the requested policy. It is not the same thing as the
enforced policy. Always inspect Process.sandboxReport after construction to
see which categories were actually enforced by the selected backend.
Properties
mode: 'strict' | 'bestEffort'
Requested sandbox mode.
strict is a security-boundary request and fails closed unless a strict
backend can enforce the policy before child code runs. bestEffort is not a
security boundary; it only reports the platform/backend subsets that were
enforced.
resources?: ProcessSandboxResources
Resource limits requested for the child and descendants.
filesystem?: ProcessSandboxFilesystem
Filesystem access policy requested for the child.
network?: ProcessSandboxNetwork
Network access policy requested for the child.
process?: ProcessSandboxProcessPolicy
Process creation and execution policy requested for the child.
syscalls?: ProcessSandboxSyscalls
Syscall policy requested for the child.
interface ProcessSandboxResources {
Resource limits requested for a sandboxed process.
Properties
memoryBytes?: number
Maximum resident or cgroup memory in bytes when the backend supports an enforcing memory limit.
pids?: number
Maximum process count when the backend supports PID-count limits.
cpu?: number
CPU quota as a fraction where 1 means one full CPU.
interface ProcessSandboxFilesystem {
Filesystem policy requested for a sandboxed process.
Properties
writable?: string[]
Writable absolute paths requested inside the sandbox view.
readonly?: string[]
Readonly absolute paths requested inside the sandbox view.
interface ProcessSandboxNetwork {
Network policy requested for a sandboxed process.
Properties
outbound?: ProcessSandboxNetworkRule[]
Outbound connect rules.
inbound?: ProcessSandboxNetworkRule[]
Inbound bind/listen rules.
interface ProcessSandboxNetworkRule {
A single network policy rule.
Properties
action: 'allow' | 'deny'
Whether matching traffic should be allowed or denied.
destination?: string
Destination host, IP, CIDR, or *, depending on backend support.
port?: number
TCP or UDP port.
protocol?: 'tcp' | 'udp'
Network protocol covered by the rule.
interface ProcessSandboxProcessPolicy {
Process creation policy requested for a sandboxed process.
Properties
allowFork?: boolean
Whether the child may fork or clone additional processes.
allowExec?: boolean
Whether the child may exec a different binary after startup.
allowedBinaries?: string[]
Executable basenames or paths allowed by an enforcing backend.
interface ProcessSandboxSyscalls {
Syscall policy requested for a sandboxed process.
Properties
mode: 'allowlist' | 'denylist'
Syscall policy mode.
names: string[]
Syscall names in the selected policy mode.
interface ProcessSandboxReport {
Effective sandbox report attached to each Process.
A report is intentionally separate from the requested sandbox options so
callers can distinguish intent from enforcement. Best-effort mode is never a
security boundary.
Properties
mode: 'none' | 'strict' | 'bestEffort'
Requested mode, or none when no sandbox was requested.
backend: 'none' | 'linuxNative' | 'macosSeatbelt'
Backend selected for the spawn.
securityBoundary: boolean
Whether the process is protected by a security boundary.
supported: ProcessSandboxCapability[]
Policy categories supported by the selected backend.
enforced: ProcessSandboxCapability[]
Policy categories that were enforced.
unsupported: ProcessSandboxCapability[]
Requested categories that were not enforced.
diagnostics: string[]
Backend and platform diagnostics that explain capability decisions.
violationBehavior: ProcessSandboxViolationBehavior[]
Claimed behavior for denied operations.
interface ProcessSandboxCapability {
Per-category sandbox capability status.
Properties
category: 'resources' | 'filesystem' | 'network' | 'process' | 'syscalls'
Policy category covered by this report entry.
reason: string
Human-readable explanation suitable for diagnostics.
interface ProcessSandboxViolationBehavior {
Denial behavior reported for an enforced sandbox category.
Properties
category: ProcessSandboxCapability['category']
Policy category covered by this behavior.
behavior: 'kill' | 'eperm' | 'denySpawn' | 'auditOnly'
Observable behavior when the backend denies an operation.
reason: string
Human-readable explanation suitable for diagnostics.
interface ProcessSandboxCapabilities {
Sandbox capability summary for the current runtime process.
Properties
platform: string
Platform identifier used by the current runtime.
strictAvailable: boolean
Whether ProcessOptions.sandbox.mode = 'strict' can currently enforce a
security boundary.
bestEffortAvailable: boolean
Whether reporting-only best-effort sandbox metadata can be attached to a spawned process.
backends: ProcessSandboxBackendCapability[]
Backends known to this runtime and their current availability.
features: ProcessSandboxNativeFeature[]
Native platform features probed by the host runtime before JS startup.
interface ProcessSandboxNativeFeature {
Native sandbox-related host capability.
Properties
name: string
Stable feature name.
available: boolean
Whether the feature appeared available at startup.
reason: string
Human-readable explanation for the feature state.
interface ProcessSandboxBackendCapability {
Availability details for a sandbox backend.
Properties
name: ProcessSandboxReport['backend']
Backend name used by ProcessSandboxReport.backend.
available: boolean
Whether the backend can currently be selected.
reason: string
Human-readable reason for the current availability state.
supported: ProcessSandboxCapability['category'][]
Policy categories this backend can enforce when available.
interface WaitResult {
Exit status returned by Process.wait.
Exactly one of code or signal is usually non-null. Both can be null for
status states the decoder does not currently expose, such as stopped
children.
import type { WaitResult } from 'fino:process';
const result: WaitResult = { code: 0, signal: null };
Properties
code: number | null
Numeric exit code for a normally exited child.
The value is null when the child was killed by a signal or when the
status could not be decoded as a normal exit.
import { Process } from 'fino:process';
const result = await new Process('/bin/true', []).wait();
console.log(result.code);
signal: number | null
Signal number that terminated the child.
The value is null for normal exits. Use exported signal constants such as
SIGTERM when comparing known signals.
import { Process, SIGTERM } from 'fino:process';
const result = await new Process('/bin/sleep', ['1']).wait();
console.log(result.signal === SIGTERM);
interface ProcessStats {
Best-effort runtime statistics for the current process.
Properties
pid: number
rssBytes: number
heapUsedBytes?: number
heapTotalBytes?: number
externalBytes?: number
eventLoopLagMs: number
timestamp: number
Functions
function processStats(): ProcessStats
Return best-effort current process statistics.
RSS is read from getrusage(RUSAGE_SELF). Heap fields are present only when
the runtime has a heap-stat source for the active platform.
function processStatsSignal(intervalMs = 1e3): ReadonlySignal<ProcessStats>
Cold signal of process statistics sampled on an interval.
function exit(code: number = 0): never
Terminate the current process immediately.
Flushes buffered stdout/stderr before exiting so pending console output is
not lost. Uses _exit (not exit(3)) after the flush to avoid running C
atexit handlers.
This function never returns. Errors during stream flushing are swallowed so the process still exits.
import { exit } from 'fino:process';
exit(0);
function cwd(): string
Return the current working directory.
Throws if getcwd(2) fails. The returned string is decoded as UTF-8 from a
fixed-size buffer.
import { cwd } from 'fino:process';
console.log(cwd());
function chdir(path: string): void
Change the current working directory. Throws on failure.
The change affects the whole current process and therefore all realms in the process that consult process cwd. Use absolute paths for predictable results.
import { chdir, cwd } from 'fino:process';
chdir('/tmp');
console.log(cwd());
function kill(targetPid: number, signal: number): void
Send a signal to a process. Throws if the syscall fails.
The target may be the current process, a child, or any process permitted by the operating system. Passing an invalid PID or signal causes an error.
import { kill, pid, SIGTERM } from 'fino:process';
kill(pid, SIGTERM);
function stdin(): FdReader
Returns a Reader for the current process's stdin (fd 0). Sets the fd to non-blocking mode on first call.
The same FdReader instance is returned on subsequent calls. The call can
throw if changing fd 0 to non-blocking mode fails.
import { stdin } from 'fino:process';
for await (const chunk of stdin()) console.log(chunk.byteLength);
function stdout(): FdWriter
Returns a Writer for the current process's stdout (fd 1). Sets the fd to non-blocking mode on first call.
The same FdWriter instance is returned on subsequent calls. Use
flushSync() before abrupt exits when output ordering matters.
import { stdout } from 'fino:process';
await stdout().write(new TextEncoder().encode('hello\n'));
function stderr(): FdWriter
Returns a Writer for the current process's stderr (fd 2). Sets the fd to non-blocking mode on first call.
The same FdWriter instance is returned on subsequent calls. The call can
throw if changing fd 2 to non-blocking mode fails.
import { stderr } from 'fino:process';
await stderr().write(new TextEncoder().encode('error\n'));
function processSandboxCapabilities(): ProcessSandboxCapabilities
Report sandbox backend capabilities for the current runtime.
This function does not spawn a process and does not imply enforcement. It is
intended for feature detection and diagnostics before choosing
ProcessOptions.sandbox. strictAvailable reflects whether an enforcing
backend can be selected on this host; bestEffort is always available as
reporting metadata only.
import { processSandboxCapabilities } from 'fino:process';
const caps = processSandboxCapabilities();
if (!caps.strictAvailable) console.log(caps.backends);
function signal(name: string): Topic
Subscribe to a POSIX signal via a Topic.
Returns a named Topic ('process:<NAME>') that publishes { signal, signo }
each time the signal is delivered. On the first call for a given signal name,
the signal is registered with the event loop so that delivery does not kill
the process. Subsequent calls return the same topic without re-registering.
import { signal } from './process.ts';
const handle = signal('SIGTERM').subscribe(({ signal }) => {
console.log(`Received ${signal}, shutting down...`);
handle.dispose();
});
// Later: handle.dispose() to unsubscribe
Unknown signal names throw. The returned topic is shared by signal name, so multiple calls subscribe to the same event source.
Constants
const pid
Current process ID.
This value is read once from getpid() during module evaluation and is
stable for the lifetime of the process.
import { pid } from 'fino:process';
console.log(`running as ${pid}`);
const ppid
Parent process ID.
This value is read from getppid() during module evaluation. It may not
reflect later parent changes caused by reparenting after startup.
import { ppid } from 'fino:process';
console.log(`parent ${ppid}`);
const SIGHUP
Hangup signal number.
import { SIGHUP, signal } from 'fino:process';
signal('SIGHUP').subscribe(({ signo }) => console.log(signo === SIGHUP));
const SIGINT
Interrupt signal number.
import { SIGINT, signal } from 'fino:process';
signal('SIGINT').subscribe(({ signo }) => console.log(signo === SIGINT));
const SIGQUIT
Quit signal number.
import { SIGQUIT } from 'fino:process';
console.log(SIGQUIT);
const SIGKILL
Kill signal number.
SIGKILL cannot be caught or handled by signal(), but it can be sent with
kill() where the operating system permits it.
import { kill, pid, SIGKILL } from 'fino:process';
kill(pid, SIGKILL);
const SIGUSR1
User-defined signal 1 number.
The numeric value is platform-aware: Linux and Darwin use different values.
import { SIGUSR1, signal } from 'fino:process';
signal('SIGUSR1').subscribe(({ signo }) => console.log(signo === SIGUSR1));
const SIGUSR2
User-defined signal 2 number.
The numeric value is platform-aware: Linux and Darwin use different values.
import { SIGUSR2, signal } from 'fino:process';
signal('SIGUSR2').subscribe(({ signo }) => console.log(signo === SIGUSR2));
const SIGPIPE
Broken pipe signal number.
import { SIGPIPE } from 'fino:process';
console.log(SIGPIPE);
const SIGALRM
Alarm signal number.
import { SIGALRM, signal } from 'fino:process';
signal('SIGALRM').subscribe(({ signal }) => console.log(signal));
const SIGTERM
Termination signal number.
This is the default signal used by Process.kill().
import { Process, SIGTERM } from 'fino:process';
const proc = new Process('/bin/sleep', ['10']);
proc.kill(SIGTERM);
const SIGCHLD
Child-status signal number.
The numeric value is platform-aware. This signal is delivered when child process status changes.
import { SIGCHLD, signal } from 'fino:process';
signal('SIGCHLD').subscribe(({ signo }) => console.log(signo === SIGCHLD));
Classes
class Process {
Spawn a child process with piped stdin, stdout, and stderr.
The child is launched via posix_spawnp(). The parent receives:
stdin- a Writer to send bytes to the child's stdinstdout- a Reader to receive bytes from the child's stdoutstderr- a Reader to receive bytes from the child's stderr
Construction throws if pipe creation, spawn file-action setup, process spawn, or parent-side non-blocking setup fails.
import { Process } from 'fino:process';
const proc = new Process('/usr/bin/cat', []);
await proc.stdin.write(new TextEncoder().encode('hello\n'));
proc.stdin.close();
for await (const chunk of proc.stdout) {
console.log(new TextDecoder().decode(chunk));
}
const { code } = await proc.wait();
Constructors
constructor(command: string, cmdArgs: string[], opts?: ProcessOptions)
Spawn a child process.
The command should be an executable path accepted by posix_spawnp(3).
Arguments exclude argv[0]; the constructor prepends command. opts.env
replaces the inherited environment snapshot, and opts.cwd is applied by
libc spawn file actions when supported by the platform.
Standard input, output, and error are always exposed as pipes on the
returned object. This constructor does not interpret Node-style shell,
stdio, detached, ipc, uid, or gid options.
import { Process } from 'fino:process';
const proc = new Process('/bin/echo', ['hello'], { cwd: '/tmp' });
const result = await proc.wait();
Getters
get stdin()
Writer connected to the child's stdin.
Close this writer when no more input will be sent so programs waiting for EOF can exit. The writer is backed by a non-blocking pipe fd.
import { Process } from 'fino:process';
const proc = new Process('/usr/bin/cat', []);
await proc.stdin.write(new TextEncoder().encode('hello\n'));
proc.stdin.close();
get stdout()
Reader connected to the child's stdout.
The reader yields Uint8Array chunks until the child closes stdout. It is
backed by a non-blocking pipe fd.
import { Process } from 'fino:process';
const proc = new Process('/bin/echo', ['hello']);
for await (const chunk of proc.stdout) console.log(chunk.byteLength);
get stderr()
Reader connected to the child's stderr.
The reader yields Uint8Array chunks until the child closes stderr. Drain
it when running commands that may write enough stderr to fill the pipe.
import { Process } from 'fino:process';
const proc = new Process('/bin/sh', ['-c', 'echo error >&2']);
for await (const chunk of proc.stderr) console.log(chunk.byteLength);
get pid()
Child process ID returned by posix_spawnp().
The PID is available immediately after construction and remains the same after the child exits.
import { Process } from 'fino:process';
const proc = new Process('/bin/sleep', ['1']);
console.log(proc.pid);
get sandboxReport(): ProcessSandboxReport
Effective sandbox report for this child process.
The report describes what was actually enforced, not just what was
requested in ProcessOptions.sandbox. When no sandbox was requested the
report uses mode: 'none'. bestEffort reports are diagnostic only and do
not indicate a security boundary.
import { Process } from 'fino:process';
const proc = new Process('/bin/echo', ['hello'], {
sandbox: { mode: 'bestEffort' }
});
console.log(proc.sandboxReport.securityBoundary);
Methods
async wait(): Promise<WaitResult>
Wait for the child process to exit using kernel notifications.
- macOS: registers EVFILT_PROC via kqueue - zero-latency, zero-CPU wait.
- Linux: opens a pidfd via pidfd_open(2) and polls it with loop.readable() - the pidfd becomes readable the moment the child exits.
After the kernel signals exit, a single waitpid(pid, 0) reaps the zombie.
Calling wait() more than once is not supported because the first call
reaps the process. The promise rejects if the platform wait primitive
cannot be created.
import { Process } from 'fino:process';
const proc = new Process('/bin/true', []);
const result = await proc.wait();
console.log(result.code);
kill(signal: number = _SIGTERM): void
Send a signal to the child process.
Defaults to SIGTERM. This method does not wait for the child to exit and
throws when the underlying kill(2) call fails, for example after the
child has already been reaped.
import { Process, SIGTERM } from 'fino:process';
const proc = new Process('/bin/sleep', ['10']);
proc.kill(SIGTERM);