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 the unsent bytes, closes Process stdin, and reports output_limit. A prefix already written directly before a partial write exposed the oversized remainder cannot be recalled.

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.