Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

procman is a Foreman-like process supervisor written in Rust. It reads a procman.yaml configuration file, spawns all listed commands, and multiplexes their output to the terminal with right-aligned name prefixes. When any child exits or a signal arrives, procman tears everything down cleanly.

Key Features

  • Dependency-aware startup ordering — processes can wait on HTTP health checks, TCP ports, file existence, file content, or the exit of another process before starting.
  • Multiplexed output — every line is prefixed with the originating process name, right-aligned for easy scanning.
  • Per-process log files — each process gets its own log in procman-logs/, plus a combined procman.log.
  • Process output templates — a process can write key=value pairs to a well-known file; downstream processes reference them with ${{ process.key }} syntax.
  • Fan-out — use for_each with a glob pattern to spawn multiple instances of a process template.
  • Dynamic process managementprocman serve listens on a FIFO so you can add processes at runtime with procman start.
  • Clean shutdown — Ctrl-C sends SIGTERM to every child, waits 2 seconds, then sends SIGKILL to anything still running.

Installation

cargo install procman

Or clone and build from source:

git clone https://github.com/wbbradley/procman.git
cd procman
cargo install --path .

Getting Started

A Minimal Configuration

Create a file called procman.yaml in your project root:

web:
  run: python3 -m http.server 8000

api:
  run: node server.js

Each top-level key is a process name, and run is the command to execute. That’s all you need.

Running

Start everything with:

procman run

Or simply:

procman

The run subcommand is the default. procman reads procman.yaml from the current directory, spawns both processes, and multiplexes their output:

   web | Serving HTTP on 0.0.0.0 port 8000
   api | Server listening on port 3000
   web | 127.0.0.1 - "GET / HTTP/1.1" 200 -

Process names are right-aligned and separated from output by a | character, making it easy to scan which process produced each line.

Log Files

procman automatically writes logs to a procman-logs/ directory:

  • procman-logs/web.log — output from the web process
  • procman-logs/api.log — output from the api process
  • procman-logs/procman.log — combined output from all processes

These files are created fresh on each run.

Stopping

Press Ctrl-C to shut down. procman sends SIGTERM to every child process, waits up to 2 seconds for them to exit, then sends SIGKILL to anything still running. procman exits with the exit code of the first process that terminated.

A More Advanced Example

Here’s a configuration that uses once processes and dependencies:

migrate:
  run: db-migrate up
  once: true

web:
  env:
    PORT: "3000"
  run: serve --port $PORT

api:
  depends:
    - process_exited: migrate
    - url: http://localhost:3000/health
      code: 200
      poll_interval: 0.5
      timeout_seconds: 30
  run: api-server start

In this setup:

  • migrate runs the database migration and exits. The once: true flag means its successful exit (code 0) won’t trigger a shutdown of everything else.
  • web starts immediately and serves on port 3000.
  • api waits for two things before starting: the migrate process must have exited successfully, and the web server’s health endpoint must be returning HTTP 200. Only then does api-server start run.

This is the core pattern for dependency-aware startup — later chapters cover the full set of dependency types and more advanced features like fan-out and process output templates.

Configuration Reference

Procman reads a single YAML file (default procman.yaml) where each top-level key defines a process. This chapter covers every field in detail.

Top-level structure

The config file is a YAML map of process-name → process definition:

web:
  run: ./start-web --port 8080

worker:
  env:
    RUST_LOG: debug
  run: cargo run --bin worker
  depends:
    - url: http://localhost:8080/health
      code: 200

Process names become the labels used in log output, dependency references, and template expressions.

Fields

run (required)

The command to execute. How the command is executed depends on whether it is single-line or multi-line:

Single-line commands are tokenized with POSIX shell quoting rules (via shell_words) and exec’d directly — no shell is involved:

api:
  run: cargo run --release --bin api-server

Multi-line commands (using YAML’s | literal block scalar, which preserves newlines) are passed to sh -c as a script. Shell features like pipes, redirects, &&, and variable expansion work naturally:

migrate:
  run: |
    ./run-migrations
    echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT

Tip: YAML’s > (folded block scalar) joins lines with spaces, producing a single-line command that is tokenized and exec’d directly — it does not invoke sh.

The run field also supports template references (${{ process.key }}). When templates are present, shell quoting validation is deferred until after template resolution at spawn time.

An empty or whitespace-only run value is rejected at parse time.

env (optional)

A map of extra environment variables merged into the process’s environment. The OS environment is inherited first, then these values are layered on top (overriding any collisions).

worker:
  env:
    RUST_LOG: debug
    PORT: "3000"
  run: my-server --port 3000

Values may contain template references:

app:
  env:
    DB_URL: "${{ migrate.DATABASE_URL }}"
  run: ./start-app
  depends:
    - process_exited: migrate

once (optional, default false)

When true, the process is expected to run to completion. An exit code of 0 is treated as success and does not trigger supervisor shutdown. A non-zero exit code still triggers shutdown.

Processes with once: true can write key-value output to their $PROCMAN_OUTPUT file, which other processes can read via template references.

migrate:
  run: ./run-migrations
  once: true

depends (optional)

A list of dependency objects that must all be satisfied before the process is started. See the Dependencies chapter for the full reference.

api:
  depends:
    - url: http://localhost:8080/health
      code: 200
    - process_exited: migrate
  run: ./start-api

for_each (optional)

Fan-out configuration that spawns one instance of the process per glob match. Requires two sub-fields:

FieldTypeDescription
globstringGlob pattern to match files
asstringEnvironment variable name that receives the matched path

Each glob match spawns a separate process instance. The variable named by as is set in the instance’s environment and substituted into the run string.

nodes:
  for_each:
    glob: "configs/node-*.yaml"
    as: CONFIG_PATH
  run: ./start-node --config $CONFIG_PATH
  once: true

Fan-out group completion is tracked so that process_exited dependencies on the template process name work transparently — the dependency is satisfied only once all instances have exited.

Environment variable expansion

Dependency paths (for url, tcp, path, and file_contains.path fields) support environment variable expansion at parse time:

SyntaxBehavior
$VARReplaced with the value of VAR
${VAR}Replaced with the value of VAR (braced form)
$$Escaped literal $

If a referenced variable is not set, the expression is left unchanged (e.g. $UNKNOWN remains $UNKNOWN).

api:
  depends:
    - path: $HOME/.config/ready.flag
    - url: http://localhost:${API_PORT}/health
      code: 200
  run: ./start-api

Parse-time validation

Procman validates the configuration at parse time and exits with an error if any of these checks fail:

  • Non-empty run: every process must have a non-empty run command.
  • Shell quoting: single-line run commands without template references are parsed with shell_words to catch unterminated quotes. Multi-line commands skip this check (they are only validated for non-empty content).
  • Dependency graph cycles: process_exited dependencies are checked for circular references using a DFS traversal. The error message shows the full cycle path (e.g. circular dependency: a -> b -> c -> a).
  • Unknown dependencies: a process_exited dependency referencing a process name not defined in the config is rejected.
  • Template validation: template references (${{ process.key }}) are checked against three rules — see Templates for details.

Dependencies

Dependencies let you control process startup order. Each dependency is polled in a loop until it is satisfied or its timeout expires. A process is not started until all of its dependencies are met.

Dependency types

HTTP health check

Wait for an HTTP endpoint to return an expected status code.

depends:
  - url: http://localhost:8080/health
    code: 200
FieldTypeRequiredDefaultDescription
urlstringyesURL to GET
codeintegeryesExpected HTTP status code
poll_intervalfloatno1.0Seconds between polls
timeout_secondsintegerno60Seconds before giving up

The HTTP client uses a 5-second per-request timeout. Only the status code is checked — the response body is ignored.

TCP connect

Wait for a TCP port to accept connections.

depends:
  - tcp: "127.0.0.1:5432"
FieldTypeRequiredDefaultDescription
tcpstringyesAddress in host:port form
poll_intervalfloatno1.0Seconds between polls
timeout_secondsintegerno60Seconds before giving up

Each poll attempt uses a 1-second connect timeout.

File exists

Wait for a file to appear on disk.

depends:
  - path: /tmp/ready.flag
FieldTypeRequiredDefaultDescription
pathstringyesPath to check

Poll interval is 1 second. Timeout is 60 seconds. These are not configurable for this dependency type.

File contains key

Wait for a file to contain a specific key, with optional value extraction into the process environment.

depends:
  - file_contains:
      path: /tmp/config.yaml
      format: yaml
      key: "$.database.url"
      env: DATABASE_URL
FieldTypeRequiredDefaultDescription
pathstringyesPath to the file
formatstringyes"json" or "yaml"
keystringyesJSONPath expression (RFC 9535)
envstringnoIf set, the resolved value is injected as this env var
poll_intervalfloatno1.0Seconds between polls
timeout_secondsintegerno60Seconds before giving up

The key field accepts a JSONPath expression (RFC 9535). Use $ to refer to the document root, . to traverse nested maps, and bracket notation for array filtering. The first matching value is used. Scalar values (strings, numbers, booleans) are converted to strings. Null values are treated as missing. Mappings and sequences are serialized as JSON strings.

Array filtering example — extract rpc from the entry where alias == "local":

depends:
  - file_contains:
      path: /tmp/sui_client.yaml
      format: yaml
      key: "$.envs[?(@.alias == 'local')].rpc"
      env: SUI_RPC_URL

When env is specified, the value at key is extracted and injected into the dependent process’s environment under that variable name. This happens after all dependencies are satisfied, just before the process is spawned. See Templates for more on passing data between processes.

Process exited

Wait for another process to exit successfully (once: true processes only, in practice).

depends:
  - process_exited: migrate
FieldTypeRequiredDefaultDescription
process_exitedstringyesName of the process to wait for

Poll interval is 100ms. Timeout is 60 seconds. These are not configurable for this dependency type.

This dependency is satisfied when the named process has exited (with any exit code). It is typically used with once: true processes like migrations or setup scripts.

For for_each processes, a process_exited dependency on the template name is satisfied only when all fan-out instances have exited.

How polling works

When a process has dependencies, procman spawns a dedicated waiter thread that evaluates dependencies in declaration order. Each dependency is fully satisfied before the next one is evaluated:

  1. Start with the first dependency.
  2. Poll the current dependency using its check function.
  3. If the check succeeds, log dependency satisfied: <description> and advance to the next dependency.
  4. If the check fails for the first time, log dependency not ready: <description> (logged only once per dependency to avoid noise).
  5. If the check fails, sleep for the dependency’s poll_interval and retry.
  6. Once all dependencies are satisfied, proceed to spawn the process.

This sequential evaluation prevents stale-data races — for example, a file_contains dependency listed after a process_exited dependency will not be checked until the process has actually exited, ensuring it reads freshly generated data rather than leftovers from a prior run.

Timeout behavior

Each dependency’s timeout clock starts when that dependency begins being evaluated (i.e., when the previous dependency is satisfied), not when the waiter thread starts. This means total wall-clock time for a process with multiple dependencies is the sum of individual wait times rather than the maximum.

If any single dependency exceeds its timeout:

  1. The waiter logs dependency timed out: <description>.
  2. The global shutdown flag is set.
  3. All processes are torn down (SIGTERM, then SIGKILL after a grace period).

A timed-out dependency is fatal — procman does not continue with partial dependencies.

Circular dependency detection

At parse time, procman builds a directed graph from process_exited dependencies and runs a DFS cycle detection pass. If a cycle is found, parsing fails with an error showing the full cycle path:

Error: circular dependency: a -> b -> c -> a

Self-dependencies (a -> a) are also detected. References to process names not defined in the config file are rejected with:

Error: process 'a' depends on unknown process 'nonexistent'

Environment variable expansion in paths

The url, tcp, path, and file_contains.path fields support environment variable expansion at parse time. See the Configuration chapter for the full syntax.

depends:
  - path: $HOME/.config/app/ready.flag
  - tcp: "${DB_HOST}:5432"

Process Output Templates

Templates let processes pass data to each other. A once: true process writes key-value output, and downstream processes reference those values in their run command or env map.

PROCMAN_OUTPUT

Every process receives a PROCMAN_OUTPUT environment variable pointing to a per-process output file at procman-logs/<name>.output. Processes can write key-value data to this file, which other processes can then read via template references.

Output file format

The output file supports two formats:

Simple key-value lines:

DATABASE_URL=postgres://localhost:5432/mydb
API_KEY=secret123

Heredoc blocks for multi-line values:

CERT<<EOF
-----BEGIN CERTIFICATE-----
MIIBxTCCAWugAwIBAgIJALP...
-----END CERTIFICATE-----
EOF

The heredoc delimiter is arbitrary — key<<DELIM starts a block and a line containing only DELIM ends it.

Template syntax

Reference another process’s output with ${{ process.key }}:

migrate:
  run: ./run-migrations
  once: true

api:
  depends:
    - process_exited: migrate
  env:
    DB_URL: "${{ migrate.DATABASE_URL }}"
  run: ./start-api --db "${{ migrate.DATABASE_URL }}"

Templates can appear in both run and env values. Multiple template references can appear in a single string and can be mixed with literal text.

Tip: In multi-line run scripts (executed via sh -c), quote template references to protect against whitespace or special characters in resolved values:

run: |
  echo "Connecting to ${{ migrate.DATABASE_URL }}"
  exec ./start-api --db "${{ migrate.DATABASE_URL }}"

Resolution

Template resolution happens at spawn time, after all dependencies for the process are satisfied. The resolver:

  1. Reads the referenced process’s output file (procman-logs/<process>.output).
  2. Parses it into a key-value map.
  3. Substitutes each ${{ process.key }} with the corresponding value.

If a referenced key is not found in the output file, resolution fails and the process is not started.

Validation rules

Procman enforces three rules at parse time to catch template errors before any process starts:

Rule 1: Referenced process must exist

app:
  run: echo ${{ nonexistent.KEY }}  # Error: process 'nonexistent' does not exist

Rule 2: Referenced process must be once: true

Only once: true processes produce output that is guaranteed to be available. Referencing a long-running process is rejected:

server:
  run: ./start-server  # not once: true

app:
  run: echo ${{ server.PORT }}  # Error: process 'server' is not once: true

Rule 3: Referencing process must depend on the referenced process

The referencing process must have a process_exited dependency (direct or transitive) on the referenced process. This guarantees the output file exists when templates are resolved:

setup:
  run: ./setup
  once: true

app:
  run: echo ${{ setup.KEY }}  # Error: no process_exited dependency on 'setup'

Transitive dependencies are followed — if app depends on middle and middle depends on setup, then app can reference setup’s output.

file_contains with env

The file_contains dependency type offers an alternative way to pass data between processes. When the env field is specified, the value at the given key is extracted and injected as an environment variable:

setup:
  run: ./generate-config
  once: true

api:
  depends:
    - process_exited: setup
    - file_contains:
        path: procman-logs/setup.output
        format: yaml
        key: database.url
        env: DATABASE_URL
  run: ./start-api

This approach does not require template syntax — the value is available as a regular environment variable ($DATABASE_URL).

End-to-end example

A migration process writes a database URL, and the API server reads it via a template:

migrate:
  run: |
    ./run-migrations
    echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
  once: true

api:
  depends:
    - process_exited: migrate
  env:
    DB_URL: "${{ migrate.DATABASE_URL }}"
  run: ./start-api --db "${{ migrate.DATABASE_URL }}"

The sequence:

  1. migrate starts and runs migrations.
  2. migrate writes DATABASE_URL=postgres://... to its $PROCMAN_OUTPUT file.
  3. migrate exits with code 0 — procman marks it as complete.
  4. api’s process_exited: migrate dependency is satisfied.
  5. Procman resolves ${{ migrate.DATABASE_URL }} by reading migrate’s output file.
  6. api starts with DB_URL set in its environment and the URL substituted into its run command.

Fan-out (for_each glob)

The for_each field lets you spawn one process instance per glob match. This is useful when the number of configuration files (or similar inputs) is not known at authoring time.

YAML syntax

Add a for_each block to a process definition with two required sub-fields:

FieldDescription
globA glob pattern (e.g. "/etc/nodes/*.yaml").
asThe name of the variable that receives each matched path.
nodes:
  for_each:
    glob: "/etc/nodes/*.yaml"
    as: CONFIG_PATH
  run: node-agent --config $CONFIG_PATH
  once: true

Variable substitution

For each glob match the as variable is:

  1. Set in the instance’s environment so the child process can read it directly.
  2. Substituted into the run string — both $VAR and ${VAR} forms are replaced with the matched path before the command is executed.

Instance naming

Glob results are sorted lexicographically. Each instance is named {template_name}-{index} where the index is 0-based. For the example above, three matches would produce processes named nodes-0, nodes-1, and nodes-2.

Group completion

A process_exited dependency can reference the template name (e.g. nodes). The dependency is satisfied only when all instances have exited successfully (exit code 0). This lets you gate a downstream process on the entire fan-out group completing:

nodes:
  for_each:
    glob: "/etc/nodes/*.yaml"
    as: CONFIG_PATH
  run: provision --config $CONFIG_PATH
  once: true

deploy:
  depends:
    - process_exited: nodes
  run: deploy-cluster
  once: true

Here deploy will not start until every nodes-* instance has completed successfully.

Constraints

  • Zero matches is an error. If the glob pattern matches no files, procman exits with an error rather than silently doing nothing.
  • once: true is typical. Fan-out processes usually represent one-shot tasks (provisioning, migration, etc.). Without once: true, any instance exiting would trigger shutdown of the entire process group.

CLI Reference

Procman provides four subcommands. If no subcommand is given, run is used by default.

procman run [CONFIG]

Spawn all processes defined in the config file and wait for exit or signal.

  • CONFIG defaults to procman.yaml.
  • Acquires an exclusive advisory lock on the config file to prevent concurrent instances.
  • On SIGINT or SIGTERM, initiates graceful shutdown.
procman run                 # uses procman.yaml
procman run services.yaml   # uses a custom config
procman run -e PORT=3000 -e RUST_LOG=debug

procman serve [CONFIG]

Like run, but also listens on a FIFO for dynamically added processes. See the Dynamic Process Management chapter for details.

  • CONFIG defaults to procman.yaml.
  • The FIFO path is auto-derived from the config path (deterministic, based on the parent directory name and a path hash).
  • Also acquires an exclusive advisory lock on the config file.
procman serve &
procman serve -e PORT=3000

procman start COMMAND [--config CONFIG]

Send a run command to a running procman serve instance.

  • COMMAND is the full command line to run. The process name is derived from the program basename (e.g. "redis-server --port 6380" runs as redis-server).
  • –config defaults to procman.yaml and is used to derive the FIFO path.
  • Fails immediately with a clear error if no server is listening (uses O_NONBLOCK).
procman start "redis-server --port 6380"
procman start "worker --threads 4" --config services.yaml
procman start "my-worker" -e DB_URL=postgres://localhost/mydb

procman stop [CONFIG]

Send a shutdown command to a running procman serve instance.

  • CONFIG defaults to procman.yaml.
  • Fails immediately if no server is listening.
procman stop

-e / --env — Extra environment variables

The run, serve, and start subcommands accept a repeatable -e KEY=VALUE flag to inject environment variables without modifying procman.yaml.

procman run -e PORT=3000 -e RUST_LOG=debug
procman start "my-worker" -e DB_URL=postgres://localhost/mydb

Precedence (lowest → highest):

Sourcerun / servestart
System environmentlowestlowest (server-side)
CLI -e flagsmiddlesent as JSON env field
YAML env: blockhighestN/A (no YAML for dynamic processes)

For run and serve, YAML env: values always win over -e flags, which in turn override inherited system environment variables.

For start, the -e flags are sent to the server as part of the JSON message and merged on top of the server’s system environment.

--debug — Pause before shutdown

The run and serve subcommands accept a --debug flag that pauses the shutdown sequence when a child process fails or a dependency times out, giving you time to inspect remaining processes before they are terminated.

procman run --debug
procman serve --debug

When triggered, procman prints:

  • Which process caused the shutdown (name, PID, exit code or signal)
  • A list of processes still running (name and PID)
  • A prompt to press ENTER (or Ctrl+C) to continue with the normal shutdown sequence

The --debug flag requires an interactive terminal (stdin must be a TTY). If stdin is not a TTY, procman exits immediately with an error.

File locking

Both run and serve acquire an exclusive advisory lock (flock) on the config file before starting. If another procman instance is already running with the same config, the second instance exits immediately with an error message.

Exit code

Procman’s exit code is the exit code of the first process that terminated (the one that triggered shutdown). If the first termination was caused by a signal rather than a normal exit, the exit code is 1.

Signals

On SIGINT (Ctrl-C) or SIGTERM, procman initiates graceful shutdown: SIGTERM is sent to each child’s process group, followed by a 2-second grace period, then SIGKILL for any stragglers.

Dynamic Process Management

Procman can accept new processes at runtime through a serve + start/stop pattern. This is useful for scripted service bringup where some processes need to be added after health checks pass or after initial setup completes.

Overview

  1. Start procman in serve mode: procman serve &
  2. Wait for your services to become healthy.
  3. Add workers dynamically: procman start "redis-server --port 6380"
  4. When done, shut down cleanly: procman stop

FIFO path

The FIFO is auto-derived from the config file path. The path is deterministic — given the same config file, the FIFO path is always the same:

/tmp/procman-{parent_dir_name}-{path_hash}.fifo

Where {parent_dir_name} is the sanitized name of the config file’s parent directory (up to 32 alphanumeric characters) and {path_hash} is a hex hash of the canonical config path. This means different projects using different config files get separate FIFOs automatically.

JSON wire protocol

Messages are sent as newline-delimited JSON. Each line is a FifoMessage with a type field.

run message

Tells the server to spawn a new process.

{
  "type": "run",
  "name": "redis-server",
  "run": "redis-server --port 6380",
  "env": {"REDIS_LOG": "verbose"},
  "depends": [{"url": "http://localhost:8080/health", "code": 200}],
  "once": true
}
FieldRequiredDescription
nameyesProcess name (used in logs and deduplication).
runyesCommand to execute.
envnoExtra environment variables (merged with the server’s env).
dependsnoDependencies, same format as in the config file.
oncenoIf true, process is a one-shot task (default false).

shutdown message

Tells the server to begin graceful shutdown.

{
  "type": "shutdown",
  "user": "wbbradley",
  "message": "User-initiated via CLI"
}
FieldRequiredDescription
usernoWho requested the shutdown (for logging).
messagenoReason for the shutdown (for logging).

Name deduplication

If the same name is sent more than once, subsequent instances are automatically renamed with a numeric suffix: the second becomes name.1, the third name.2, and so on. The first use of a name is never suffixed.

Workflow example

# Start the supervisor in the background
procman serve &

# Wait for the API to become healthy
while ! curl -sf http://localhost:8080/health; do sleep 1; done

# Add a worker dynamically
procman start "redis-server --port 6380"

# Later, shut down everything
procman stop

Error behavior

procman start and procman stop open the FIFO with O_NONBLOCK. If no procman serve instance is listening, the open fails immediately with a clear error message — there is no blocking or timeout.

Logging & Output

Procman captures all process output and writes it to both the terminal and log files.

Multiplexed stdout

All process output is interleaved on procman’s stdout with right-aligned name labels and a | separator:

 procman | started with 3 process(es), mode=run
     web | listening on :8080
  worker | processing jobs
     web | GET /health 200

The name column width adjusts to the longest process name so that all | separators align.

Log directory

At startup, procman creates (or recreates) a procman-logs/ directory in the current working directory. Any existing procman-logs/ directory is removed first to ensure a clean state.

Combined log

procman-logs/procman.log contains every line from every process, in the same right-aligned format as the terminal output. This is a complete record of the session.

Per-process logs

Each process gets its own log file at procman-logs/<name>.log. These files contain only that process’s output lines with no name prefix — just the raw output. This makes them easy to feed into other tools or search with grep.

The procman pseudo-process does not get its own per-process log file. Supervisor messages appear only in the combined log and on stdout.

Supervisor messages

Procman logs its own messages under the procman name. These include:

  • Startup information (process count, mode).
  • Dependency status (waiting, satisfied).
  • Process lifecycle events (started, completed, exited, killed).
  • Shutdown sequence progress.
  • Error messages.

stderr handling

Each child process has stderr redirected to stdout (dup2) before exec. This means both streams are captured through the same pipe and appear interleaved in the logs. There is no separate stderr log.

Dynamic processes

Processes added via the FIFO (see Dynamic Process Management) get their log files created on demand. They appear in both the combined log and get their own per-process log file, just like statically configured processes.

Shutdown & Signals

When procman shuts down, it ensures every child process — and all of its descendants — receives a termination signal and is cleaned up.

What triggers shutdown

Shutdown begins when either:

  • A child process exits (unless it is once: true and exits with code 0).
  • The user sends Ctrl-C (SIGINT) or SIGTERM to procman.

Process groups

Each child process runs in its own process group (setpgid(0, 0) is called before exec). This means:

  • Signals sent to the group reach every descendant of the child, not just the top-level PID.
  • Unrelated processes managed by procman do not receive each other’s signals.

This is especially important for multi-line run commands, which are executed via sh -c. The shell spawns child processes (pipes, subshells, backgrounded commands) that would otherwise be orphaned on shutdown. Because the entire process group is signaled, those descendants are cleaned up together with the shell.

Shutdown sequence

  1. SIGTERM is sent to each remaining child’s process group (killpg).
  2. Procman waits up to 2 seconds for processes to exit cleanly.
  3. Any process groups still alive after the grace period receive SIGKILL.
  4. Procman waits for all remaining processes to be reaped.

Exit code

Procman’s exit code is the exit code of the first process that terminated (the one that triggered shutdown). If the first process was killed by a signal rather than exiting normally, the exit code is 1.