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 combinedprocman.log. - Process output templates — a process can write
key=valuepairs to a well-known file; downstream processes reference them with${{ process.key }}syntax. - Fan-out — use
for_eachwith a glob pattern to spawn multiple instances of a process template. - Dynamic process management —
procman servelistens on a FIFO so you can add processes at runtime withprocman 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 .
Links
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 thewebprocessprocman-logs/api.log— output from theapiprocessprocman-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: trueflag 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
migrateprocess must have exited successfully, and the web server’s health endpoint must be returning HTTP 200. Only then doesapi-server startrun.
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 invokesh.
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:
| Field | Type | Description |
|---|---|---|
glob | string | Glob pattern to match files |
as | string | Environment 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:
| Syntax | Behavior |
|---|---|
$VAR | Replaced 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_wordsto catch unterminated quotes. Multi-line commands skip this check (they are only validated for non-empty content). - Dependency graph cycles:
process_exiteddependencies 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_exiteddependency 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url | string | yes | — | URL to GET |
code | integer | yes | — | Expected HTTP status code |
poll_interval | float | no | 1.0 | Seconds between polls |
timeout_seconds | integer | no | 60 | Seconds 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"
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
tcp | string | yes | — | Address in host:port form |
poll_interval | float | no | 1.0 | Seconds between polls |
timeout_seconds | integer | no | 60 | Seconds 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | Path 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | Path to the file |
format | string | yes | — | "json" or "yaml" |
key | string | yes | — | JSONPath expression (RFC 9535) |
env | string | no | — | If set, the resolved value is injected as this env var |
poll_interval | float | no | 1.0 | Seconds between polls |
timeout_seconds | integer | no | 60 | Seconds 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
process_exited | string | yes | — | Name 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:
- Start with the first dependency.
- Poll the current dependency using its check function.
- If the check succeeds, log
dependency satisfied: <description>and advance to the next dependency. - If the check fails for the first time, log
dependency not ready: <description>(logged only once per dependency to avoid noise). - If the check fails, sleep for the dependency’s
poll_intervaland retry. - 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:
- The waiter logs
dependency timed out: <description>. - The global shutdown flag is set.
- 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
runscripts (executed viash -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:
- Reads the referenced process’s output file (
procman-logs/<process>.output). - Parses it into a key-value map.
- 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:
migratestarts and runs migrations.migratewritesDATABASE_URL=postgres://...to its$PROCMAN_OUTPUTfile.migrateexits with code 0 — procman marks it as complete.api’sprocess_exited: migratedependency is satisfied.- Procman resolves
${{ migrate.DATABASE_URL }}by readingmigrate’s output file. apistarts withDB_URLset in its environment and the URL substituted into itsruncommand.
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:
| Field | Description |
|---|---|
glob | A glob pattern (e.g. "/etc/nodes/*.yaml"). |
as | The 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:
- Set in the instance’s environment so the child process can read it directly.
- Substituted into the
runstring — both$VARand${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: trueis typical. Fan-out processes usually represent one-shot tasks (provisioning, migration, etc.). Withoutonce: 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 asredis-server). - –config defaults to
procman.yamland 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):
| Source | run / serve | start |
|---|---|---|
| System environment | lowest | lowest (server-side) |
CLI -e flags | middle | sent as JSON env field |
YAML env: block | highest | N/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
- Start procman in serve mode:
procman serve & - Wait for your services to become healthy.
- Add workers dynamically:
procman start "redis-server --port 6380" - 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
}
| Field | Required | Description |
|---|---|---|
name | yes | Process name (used in logs and deduplication). |
run | yes | Command to execute. |
env | no | Extra environment variables (merged with the server’s env). |
depends | no | Dependencies, same format as in the config file. |
once | no | If 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"
}
| Field | Required | Description |
|---|---|---|
user | no | Who requested the shutdown (for logging). |
message | no | Reason 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: trueand 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
- SIGTERM is sent to each remaining child’s process group (
killpg). - Procman waits up to 2 seconds for processes to exit cleanly.
- Any process groups still alive after the grace period receive SIGKILL.
- 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.