The process subsystem follows this flow:
Command/Pipeline -> validated plan -> spawn -> running
-> terminating/killed -> reaped result
render(plan(...)) stops before spawn. It only formats values for inspection.
Command validates text boundaries and policy combinations before the adapter
creates a native Julia Cmd. The adapter builds one direct command per stage,
applies the working directory and environment, and requests a detached process
group when the lifecycle policy enables process groups.
The package never turns a command string into shell source. Shell behavior
requires an explicit shell executable with a -c argument vector:
Command("/bin/sh", ["-c", "printf '%s\\n' \"\$VALUE\""];
env=EnvOverlay(["VALUE" => "trusted input"]))That is explicitly trusted shell execution, not a sandbox boundary.
spawn returns immediately with a ProcessHandle. The handle owns the Julia
process objects and capture pipes. A lifecycle task waits for completion while
reader tasks drain stdout and stderr captures concurrently.
The first event is :started. Its detail holds native process IDs and the
command count. A PID may be unavailable if a process exits before the adapter
reads it.
Capture readers retain at most the configured number of bytes. Reads into the result buffer are never unbounded.
| Policy | Behavior |
|---|---|
Capture(...; overflow=:fail) |
Stop retaining after the bound, request termination, return OutputLimitExceeded. |
Capture(...; overflow=:truncate) |
Keep draining, retain only the bound, return the underlying status with truncated=true. |
Stream(io) |
Forward output to the caller's open IO; the result retains nothing. |
Inherit() |
Connect the child to the corresponding parent descriptor. |
Null() |
Connect the child to /dev/null. |
In pipelines, internal stage connections are kernel pipes. Public captured stdout comes from the final stage; stderr follows the configured terminal sink.
The lifecycle task observes all native processes, waits for the process or
chain, joins the output readers, snapshots lifecycle events, collects resource
accounting, and publishes one ProcessResult.
Success means status === Succeeded and result.ok == true. Non-zero exit and
signal termination are returned as data by run; use run_checked to throw
instead.
When a timeout expires or cancellation is observed:
- The handle records the reason.
- The graceful signal goes to the process group, where enabled.
- The lifecycle task waits for
grace_period. - The final signal goes out if an owned process remains alive.
- The handle waits and reaps before publishing the result.
Timeout results use TimedOut with a TimeoutError recording attempted
signals. Cancellation results use Cancelled. Cancellation is idempotent: a
request after natural completion does not rewrite the published result.
The lifecycle task checks cancellation, timeout, overflow, and liveness on a short polling interval.
signal(handle, signum) sends immediately and records a :signal event.
terminate(handle) requests the graceful lifecycle path. kill(handle) sends
signal 9 immediately. Call these only from code that owns the handle.
A single command requests a detached process group by default. Linux group signaling uses a negative PID, so timeout or cancellation reaches descendants in the same group, not just the leader.
In pipelines the first stage leads the group and followers are joined into it
with setpgid on a best-effort basis. When the kernel refuses (EPERM for
session leaders, EACCES after exec), the handle falls back to per-stage group
signals and records :process_group with unified=false. Descendant cleanup
under fallback is release work with dedicated stress tests (see
../TODOS.md).
After owned processes are reaped, Linux resource values come from
getrusage(RUSAGE_CHILDREN). CPU values are seconds; maximum RSS is converted
to bytes. RUSAGE_CHILDREN accumulates reaped children of the Julia process,
so long-lived applications observe earlier child work. Per-process accounting
is not implemented.
CleanupResult reports whether native process waiting completed and whether
owned capture descriptors closed without a recorded error. Explicit wait is
the cleanup path; Julia finalizers are not part of the contract.
On cleanup failure, inspect result.cleanup.errors and the lifecycle event
trace. Do not silently retry a resource-sensitive operation while a descendant
may still be running.
Published event kinds:
:startedonce native processes exist;:process_groupwith unification outcome for multi-stage pipelines;:signalfor an explicit immediate signal request;:terminatingwhen graceful escalation begins;:killedwhen the final signal is sent;:output_collectedand:output_overflowper capture channel, with byte counts;:cleanup_errorwhen lifecycle cleanup records an exception;:completedimmediately before the terminal result snapshot.