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:

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 stdin
  • stdout - a Reader to receive bytes from the child's stdout
  • stderr - 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);