From 6b1b98b662372ec7ef495e2c42161b61648e860a Mon Sep 17 00:00:00 2001 From: Jonas Kattendick Date: Sun, 17 Aug 2025 17:16:56 +0200 Subject: [PATCH] feat: emulate rust's process api more closely Yoink some docs as well --- src/Child.php | 157 ++++++++++++++++++++++++++++++------- src/Command.php | 188 +++++++++++++++++++++++++++++++++++---------- src/ExitStatus.php | 40 +++++++++- src/Stdio.php | 21 +++++ 4 files changed, 335 insertions(+), 71 deletions(-) diff --git a/src/Child.php b/src/Child.php index 428ede6..74a5aba 100644 --- a/src/Child.php +++ b/src/Child.php @@ -4,32 +4,138 @@ declare(strict_types=1); namespace Nih\CommandBuilder; +/** + * Representation of a running or exited child process. + */ final class Child { /** - * @param resource $proc The process handle. + * The child's process handle. + * + * @var resource */ - public function __construct( - public readonly ?ChildStdin $stdin, - public readonly ?ChildStdout $stdout, - public readonly ?ChildStderr $stderr, - public $proc, - ) { + private readonly mixed $process; + + /** + * The child's process identifier. + */ + private readonly int $id; + + /** + * The child's exit status. + */ + private ?ExitStatus $status = null; + + /** + * The handle for writing to the child’s standard input (stdin), if it has + * been captured. + */ + public readonly ?ChildStdin $stdin; + + /** + * The handle for reading from the child’s standard output (stdout), if it + * has been captured. + */ + public readonly ?ChildStdout $stdout; + + /** + * The handle for reading from the child’s standard error (stderr), if it + * has been captured. + */ + public readonly ?ChildStderr $stderr; + + /** + * @param resource $process The child's process handle. + * @param array $pipes File pointers. + */ + public function __construct(mixed $process, array $pipes) + { + $this->process = $process; + + $status = proc_get_status($this->process); + $this->id = $status['pid']; + + $this->stdin = array_key_exists(0, $pipes) + ? new ChildStdin($pipes[0]) + : null; + + $this->stdout = array_key_exists(1, $pipes) + ? new ChildStdout($pipes[1]) + : null; + + $this->stderr = array_key_exists(2, $pipes) + ? new ChildStderr($pipes[2]) + : null; } + /** + * Returns the OS-assigned process identifier associated with this child. + */ public function id(): int { - if (!is_resource($this->proc)) { + return $this->id; + } + + /** + * Waits for the child to exit completely, returning the status that it + * exited with. This function will continue to have the same return value + * after it has been called at least once. + * + * The stdin handle to the child process, if any, will be closed before + * waiting. This helps avoid deadlock: it ensures that the child does not + * block waiting for input from the parent, while the parent waits for the + * child to exit. + * + * @throws ChildException If the resource was already closed + */ + public function wait(): ExitStatus + { + if ($this->status) { + return $this->status; + } + + if (!is_resource($this->process)) { throw new ChildException('Resource was already closed'); } - $status = proc_get_status($this->proc); - return $status['pid']; + // Avoid possible deadlock before waiting. + $this->stdin?->close(); + + $status = proc_get_status($this->process); + while ($status['running']) { + // Suboptimal, but it is what it is... + usleep(50); + $status = proc_get_status($this->process); + }; + + proc_close($this->process); + + return $this->status = new ExitStatus( + $status['exitcode'], + $status['signaled'] ? $status['termsig'] : null, + $status['stopped'] ? $status['stopsig'] : null, + ); } + /** + * Simultaneously waits for the child to exit and collect all remaining + * output on the stdout/stderr handles, returning an {@see Output} instance. + * + * The stdin handle to the child process, if any, will be closed before + * waiting. This helps avoid deadlock: it ensures that the child does not + * block waiting for input from the parent, while the parent waits for the + * child to exit. + * + * By default, stdin, stdout and stderr are inherited from the parent. In + * order to capture the output it is necessary to create new pipes between + * parent and child. Use the `stdout` and `stderr` functions of {@see + * Command}, respectively. + * + * @throws ChildException If the resource was already closed + */ public function waitWithOutput(): Output { - if (!is_resource($this->proc)) { + if (!is_resource($this->process)) { throw new ChildException('Resource was already closed'); } @@ -38,36 +144,29 @@ final class Child $stdout = $this->stdout?->getContents(); $stderr = $this->stderr?->getContents(); - $status = new ExitStatus(proc_close($this->proc)); + $status = $this->wait(); return new Output($stdout, $stderr, $status); } - public function wait(): ExitStatus - { - if (!is_resource($this->proc)) { - throw new ChildException('Resource was already closed'); - } - - // Avoid possible deadlock before waiting. - $this->stdin?->close(); - - return new ExitStatus(proc_close($this->proc)); - } - + /** + * Forces the child process to exit. + * + * This is equivalent to sending a SIGKILL. + */ public function kill(): bool { - if (!is_resource($this->proc)) { - throw new ChildException('Resource was already closed'); + if (!is_resource($this->process)) { + return true; } - return proc_terminate($this->proc, 10); + return proc_terminate($this->process, 9); } public function __destruct() { - if (is_resource($this->proc)) { - proc_close($this->proc); + if (is_resource($this->process)) { + proc_close($this->process); } } } diff --git a/src/Command.php b/src/Command.php index 6a85386..cbfa54c 100644 --- a/src/Command.php +++ b/src/Command.php @@ -4,16 +4,20 @@ declare(strict_types=1); namespace Nih\CommandBuilder; -use RuntimeException; use Stringable; -use TypeError; use ValueError; +/** + * A process builder, providing fine-grained control over how a new process + * should be spawned. + * + * TODO: Wonky env handling. + */ final class Command implements Stringable { - public readonly string $command; + public readonly string $program; - private array $args = []; + private array $args; private ?array $envs = null; private ?string $cwd = null; @@ -21,16 +25,22 @@ final class Command implements Stringable private ?Stdio $stdout = null; private ?Stdio $stderr = null; - public function __construct(string $command) + /** + * + */ + public function __construct(string $program) { - if (strlen($command) === 0) { - throw new ValueError('Empty command'); + if (strlen($program) === 0) { + throw new ValueError('Empty program name'); } - $this->command = $command; - $this->args[] = $this->command; + $this->program = $program; + $this->args = [$this->program]; } + /** + * Adds an argument to pass to the program. + */ public function arg(string|Stringable $arg): static { $this->args[] = (string) $arg; @@ -38,9 +48,11 @@ final class Command implements Stringable } /** - * @param array $args + * Adds multiple arguments to pass to the program. + * + * @param iterable $args */ - public function args(array $args): static + public function args(iterable $args): static { foreach ($args as $arg) { $this->args[] = (string) $arg; @@ -49,30 +61,71 @@ final class Command implements Stringable return $this; } - public function currentDir(string|Stringable $cwd): static + /** + * Inserts or updates an explicit environment variable mapping. + */ + public function env(string $key, string|Stringable $val): static { - $this->cwd = (string) $cwd; - return $this; - } - - public function env(string $var, string|Stringable $value): static - { - $this->envs[$var] = $value; + $this->envs[$key] = $val; return $this; } /** - * @param array $envs + * Inserts or updates multiple environment variable mappings. + * + * @param iterable $vars */ - public function envs(array $envs): static + public function envs(iterable $vars): static { - foreach ($envs as $var => $value) { - $this->envs[$var] = $value; + foreach ($vars as $key => $val) { + $this->envs[$key] = (string) $val; } return $this; } + /** + * Removes an explicitly set environment variable and prevents inheriting it + * from a parent process. + */ + public function envRemove(string $key): static + { + if (is_null($this->envs)) { + $this->envs = getenv(); + } + + if (isset($this->envs[$key])) { + unset($this->envs[$key]); + } + + return $this; + } + + /** + * Clears all explicitly set environment variables and prevents inheriting + * any parent process environment variables. + */ + public function envClear(): static + { + $this->envs = []; + return $this; + } + + /** + * Sets the working directory for the child process. + */ + public function currentDir(string|Stringable $dir): static + { + $this->cwd = (string) $dir; + return $this; + } + + /** + * Configuration for the child process’s standard input (stdin) handle. + * + * Defaults to inherit when used with spawn or status, and defaults to piped + * when used with output. + */ public function stdin(Stdio $stdin): static { $stdin = match ($stdin->type) { @@ -85,6 +138,12 @@ final class Command implements Stringable return $this; } + /** + * Configuration for the child process’s standard output (stdout) handle. + * + * Defaults to inherit when used with spawn or status, and defaults to piped + * when used with output. + */ public function stdout(Stdio $stdout): static { $stdout = match ($stdout->type) { @@ -97,6 +156,12 @@ final class Command implements Stringable return $this; } + /** + * Configuration for the child process’s standard error (stderr) handle. + * + * Defaults to inherit when used with spawn or status, and defaults to piped + * when used with output. + */ public function stderr(Stdio $stderr): static { $stderr = match ($stderr->type) { @@ -123,6 +188,10 @@ final class Command implements Stringable // } /** + * Executes the command as a child process, returning a handle to it. + * + * By default, stdin, stdout and stderr are inherited from the parent. + * * @param bool $shell Run the command with or without a shell */ public function spawn(bool $shell = true): Child @@ -141,6 +210,11 @@ final class Command implements Stringable } /** + * Executes a command as a child process, waiting for it to finish and + * collecting its status. + * + * By default, stdin, stdout and stderr are inherited from the parent. + * * @param bool $shell Run the command with or without a shell */ public function status(bool $shell = true): ExitStatus @@ -149,6 +223,12 @@ final class Command implements Stringable } /** + * Executes the command as a child process, waiting for it to finish and + * collecting all of its output. + * + * By default, stdout and stderr are captured (and used to provide the + * resulting output). Stdin is not inherited from the parent. + * * @param bool $shell Run the command with or without a shell */ public function output(bool $shell = true): Output @@ -166,6 +246,42 @@ final class Command implements Stringable ])->waitWithOutput(); } + /** + * Returns the path to the program that was given to the constructor. + */ + public function getProgram(): string + { + return $this->program; + } + + /** + * Returns the arguments that will be passed to the program. + * + * This does not include the path to the program as the first argument. + */ + public function getArgs(): array + { + return array_slice($this->args, 1); + } + + /** + * Returns the environment variables set for the child process. + */ + public function getEnvs(): array + { + return $this->envs; + } + + /** + * Returns the working directory for the child process. + * + * This returns `NULL` if the working directory will not be changed. + */ + public function getCurrentDir(): ?string + { + return $this->cwd; + } + public function __toString(): string { return implode(' ', array_map(escapeshellarg(...), $this->args)); @@ -185,33 +301,23 @@ final class Command implements Stringable if ($shell) { $command = (string) $this; - } else if (is_executable($this->command)) { + } else if (is_executable($this->program)) { $command = $this->args; } else { throw new CommandException(sprintf( - 'Command "%s" is not executable', - $this->command, + 'Program "%s" is not executable', + $this->program, )); } $proc = proc_open($command, $descriptorSpec, $pipes, $this->cwd, $this->envs); - if ($proc === false) { - throw new RuntimeException('Failed proc_open'); + throw new CommandException(sprintf( + 'Program "%s" failed to start', + $this->program, + )); } - $stdin = array_key_exists(0, $pipes) - ? new ChildStdin($pipes[0]) - : null; - - $stdout = array_key_exists(1, $pipes) - ? new ChildStdout($pipes[1]) - : null; - - $stderr = array_key_exists(2, $pipes) - ? new ChildStderr($pipes[2]) - : null; - - return new Child($stdin, $stdout, $stderr, $proc); + return new Child($proc, $pipes); } } diff --git a/src/ExitStatus.php b/src/ExitStatus.php index bf891d9..333718b 100644 --- a/src/ExitStatus.php +++ b/src/ExitStatus.php @@ -4,15 +4,53 @@ declare(strict_types=1); namespace Nih\CommandBuilder; +/** + * Describes the result of a process after it has terminated. + * + * This class is used to represent the exit status or other termination of a + * child process. Child processes are created via the {@see Command} class and + * their exit status is exposed through the status method, or the wait method of + * a Child process. + */ final class ExitStatus { public function __construct( - public readonly int $code, + private readonly ?int $code, + private readonly ?int $signal = null, + private readonly ?int $stoppedSignal = null, ) { } + /** + * Was termination successful? Signal termination is not considered a + * success, and success is defined as a zero exit status. + */ public function success(): bool { return $this->code === 0; } + + /** + * The exit code of the process, if any. + */ + public function code(): ?int + { + return $this->code > -1 ? $this->code : null; + } + + /** + * If the process was terminated by a signal, returns that signal. + */ + public function signal(): ?int + { + return $this->signal; + } + + /** + * If the process was stopped by a signal, returns that signal. + */ + public function stoppedSignal(): ?int + { + return $this->stoppedSignal; + } } diff --git a/src/Stdio.php b/src/Stdio.php index dff3063..cedbc21 100644 --- a/src/Stdio.php +++ b/src/Stdio.php @@ -6,6 +6,10 @@ namespace Nih\CommandBuilder; use Stringable; +/** + * Describes what to do with a standard I/O stream for a child process when + * passed to the stdin, stdout, and stderr methods of {@see Command}. + */ final class Stdio { public const INHERIT = 0; @@ -25,27 +29,44 @@ final class Stdio ) { } + /** + * The child inherits from the corresponding parent descriptor. + */ public static function inherit(): self { return new self(self::INHERIT, null); } + /** + * A new pipe should be arranged to connect the parent and child processes. + */ public static function piped(): self { return new self(self::PIPE, ['pipe', 'r']); } + /** + * This stream will be ignored. This is the equivalent of attaching the + * stream to `/dev/null`. + */ public static function null(): self { return self::file('/dev/null', 'a+'); } + /** + * Like piped, but instead of capturing the stream into a handle, read + * and/or write from/to a file. + */ public static function file(string|Stringable $file, string $mode): self { return new self(self::FILE, ['file', (string) $file, $mode]); } /** + * Like piped, but instead of capturing the stream into a handle, read + * and/or write from/to a stream. + * * @param resource|StreamReadable|StreamWritable $stream */ public static function stream($stream): self