Process design
Linux::Event::Process combines pidfd lifecycle notification and optional
asynchronous standard I/O in one logical Loop object. It avoids PID-reuse races
and never runs Perl code in a post-fork child.
Public type model
A concrete subclass defines on_exit:
package BuildProcess;
use parent 'Linux::Event::Process';
sub on_stdout ($process, $bytes) {
print "build: $bytes";
}
sub on_stderr ($process, $bytes) {
warn "build: $bytes";
}
sub on_exit ($process) {
if (defined(my $code = $process->exit_code)) {
say "build exited with $code";
}
else {
say "build received signal " . $process->term_signal;
}
$process->loop->stop;
}
Callbacks are cached once per subclass. One Process object owns the pidfd, stdio pipes, registrations, queue state, decoded status, and application data.
Native spawning
spawn accepts an argument vector and never inserts a shell:
my $process = $loop->add(BuildProcess->spawn(
command => ['/usr/bin/make', '-j4'], # required
cwd => '/srv/project', # optional
env => { BUILD_MODE => 'test' }, # optional replacement environment
stdin => 'pipe', # optional; default inherit
stdout => 'pipe', # optional; default inherit
stderr => 'pipe', # optional; default inherit
data => { started_by => 'api' }, # optional
));
Construction stores a specification and has no process side effect. The child
is created when the object attaches through loop => $loop or Loop->add.
pid is therefore undefined while detached.
The XS extension uses posix_spawnp. Pipes are created atomically with
O_CLOEXEC. File actions establish stdio, close temporary pipe ends and the
original source descriptor after duplicating a caller handle, and optionally
change directory through
posix_spawn_file_actions_addchdir_np. No Perl interpreter, allocator,
callback, or application lock runs between fork and exec. This remains safe
after the resolver has started native threads.
env is a complete replacement environment. Omit it to inherit the current
environment. Use an explicit ['/bin/sh', '-c', 'make clean && make'] command
only when shell parsing is intentional.
Standard I/O modes
Each standard descriptor accepts:
| Value | Child behavior | Parent ownership |
| --- | --- | --- |
| inherit | retains the corresponding parent descriptor | no new handle |
| pipe | connects to a nonblocking parent pipe end | Process owns parent end |
| null | connects to /dev/null | temporary handle closed after spawn |
| filehandle | duplicates it to stdio and closes the original child-side descriptor | caller keeps handle |
| stdout for stderr | duplicates child stdout to child stderr | follows stdout mode |
on_stdout and on_stdout_eof require stdout => 'pipe'. The corresponding
stderr callbacks require a stderr pipe, and on_stdin_drain requires a stdin
pipe. Impossible combinations fail during construction.
Standard input and backpressure
write_stdin writes immediately when possible and queues the remainder. It
can be called while detached; queued bytes are retained until attachment.
Crossing stdin_high_watermark returns false while accepting the bytes.
on_stdin_drain fires when pending bytes reach stdin_low_watermark.
A nonzero max_pending_stdin is a separate hard safety limit. Overflow rejects
only the new bytes and reports output_limit.
close_stdin is graceful: it rejects new writes, drains accepted bytes, then
closes the pipe to deliver EOF. Pipe writes block SIGPIPE only in the calling
thread, consume a signal generated by EPIPE, and restore the previous mask.
Process never installs a process-wide SIGPIPE disposition.
Existing processes
An existing PID can be observed:
my $child = $loop->add(BuildProcess->new(
pid => $pid, # required
reap => 1, # default
));
reap => 1 uses waitid(P_PIDFD) and requires the PID to be this process's
child. reap => 0 supports lifecycle notification for a non-child but leaves
all status fields undefined. Opening the pidfd during attachment pins the
kernel identity even if the numeric PID is later reused.
Exit and status
pidfd readability triggers nonblocking waitid. On a reaped child, Process
records one of an exit code or terminating signal, the core-dump flag, and a
conventional raw wait status. It then drains remaining stdout and stderr to
the current nonblocking boundary, closes stdin, and invokes on_exit exactly
once.
The final drain consumes all bytes already available when the owned child exits, then closes the Process pipe ends. It deliberately does not keep a Process alive for a descendant that inherited those descriptors.
The Loop remains available during on_exit and is released when the callback
returns, including when the callback throws. Callback exceptions propagate
from Loop dispatch after native cleanup.
Signals and cancellation
signal($number) uses pidfd_send_signal, so it cannot target a new process
that reused the same numeric PID. Failures throw a structured process Error.
There is deliberately no cancel. Stopping notification, killing a process,
closing stdin, and waiting for exit are different operations. Applications
choose a signal and continue running the Loop until on_exit confirms the
result.
Failure containment
If spawn succeeds but descriptor registration fails, Process sends SIGKILL through the pidfd, reaps that exact child, closes every partial registration, and makes the object failed before propagating the setup exception. It never falls back from an available pidfd to a numeric-PID signal. A pidfd failure immediately after spawn follows the same kill-and-reap rule before the PID can escape the native spawn operation.
Asynchronous stdio errors use process_io; pidfd and wait failures use
process. Without on_error, Process warns and retains last_error.
Ownership and Loop destruction
The Loop retains a running Process. Dropping an application reference does not cancel notification or kill the child. Conversely, destroying the Loop closes Linux::Event handles but does not secretly signal a process. Applications must keep their Loop alive and define shutdown policy until owned children are reaped, otherwise ordinary Unix zombie rules apply.
Spawned Processes and observed children with reap => 1 exclusively own their
wait status. Do not combine them with wait, waitpid, or a separate
SIGCHLD reaper for the same PID. Use reap => 0 when another component owns
reaping and only pidfd lifecycle notification is wanted.
Platform contract
Process supports Linux 5.4 or newer for pidfd status and requires build headers
with pidfd_open and pidfd_send_signal syscall definitions. The build also
requires a libc with posix_spawn_file_actions_addchdir_np. These constraints
are explicit because a fallback based on fork plus Perl child setup would be
unsafe once native worker threads exist.