Compare commits

...

2 Commits

Author SHA1 Message Date
25e4c6a151 fix: env handling and inheritance 2025-08-17 18:28:11 +02:00
6b1b98b662 feat: emulate rust's process api more closely
Yoink some docs as well
2025-08-17 17:16:56 +02:00
4 changed files with 418 additions and 73 deletions

View File

@@ -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 childs standard input (stdin), if it has
* been captured.
*/
public readonly ?ChildStdin $stdin;
/**
* The handle for reading from the childs standard output (stdout), if it
* has been captured.
*/
public readonly ?ChildStdout $stdout;
/**
* The handle for reading from the childs standard error (stderr), if it
* has been captured.
*/
public readonly ?ChildStderr $stderr;
/**
* @param resource $process The child's process handle.
* @param array<int, resource> $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);
}
}
}

View File

@@ -4,33 +4,68 @@ 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.
*/
final class Command implements Stringable
{
public readonly string $command;
public readonly string $program;
private array $args = [];
private ?array $envs = null;
private array $args;
private ?array $environment = null;
private bool $environmentInherit = true;
private ?string $cwd = null;
private ?Stdio $stdin = null;
private ?Stdio $stdout = null;
private ?Stdio $stderr = null;
public function __construct(string $command)
/**
* Constructs a new Command for launching the program at path program, with
* the following default configuration:
*
* - No arguments to the program
* - Inherit the current process's environment
* - Inherit the current process's working directory
* - Inherit stdin/stdout/stderr for spawn or status, but create pipes for
* output
*
* Builder methods are provided to change these defaults and otherwise
* configure the process.
*/
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.
*
* Only one argument can be passed per use. So instead of:
*
* ```
* $command->arg('-C /path/to/repo');
* ```
*
* usage would be:
*
* ```
* $command
* ->arg('-C')
* ->arg('/path/to/repo');
* ```
*
* To pass multiple arguments see {@see Command::args()}.
*/
public function arg(string|Stringable $arg): static
{
$this->args[] = (string) $arg;
@@ -38,9 +73,13 @@ final class Command implements Stringable
}
/**
* @param array<string|Stringable> $args
* Adds multiple arguments to pass to the program.
*
* To pass a single argument see {@see Command::arg()}.
*
* @param iterable<string|Stringable> $args
*/
public function args(array $args): static
public function args(iterable $args): static
{
foreach ($args as $arg) {
$this->args[] = (string) $arg;
@@ -49,30 +88,109 @@ final class Command implements Stringable
return $this;
}
public function currentDir(string|Stringable $cwd): static
/**
* Inserts or updates an explicit environment variable mapping.
*
* This method allows you to add an environment variable mapping to the
* spawned process or overwrite a previously set value. You can use
* {@see Command::envs()} to set multiple environment variables
* simultaneously.
*
* Child processes will inherit environment variables from their parent
* process by default. Environment variables explicitly set using
* {@see Command::env()} take precedence over inherited variables. You can
* disable environment variable inheritance entirely using
* {@see Command::envClear()} or for a single key using
* {@see Command::envRemove()}.
*/
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->environment[$key] = $val;
return $this;
}
/**
* @param array<string, string|Stringable> $envs
* Inserts or updates multiple environment variable mappings.
*
* This method allows you to add multiple environment variable mappings to
* the spawned process or overwrite previously set values. You can use
* {@see Command::env()} to set a single environment variable.
*
* Child processes will inherit environment variables from their parent
* process by default. Environment variables explicitly set using
* {@see Command::envs()} take precedence over inherited variables. You can
* disable environment variable inheritance entirely using
* {@see Command::envClear()} or for a single key using
* {@see Command::envRemove()}.
*
* @param iterable<string, string|Stringable> $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->environment[$key] = (string) $val;
}
return $this;
}
/**
* Removes an explicitly set environment variable and prevents inheriting it
* from a parent process.
*
* This method will remove the explicit value of an environment variable set
* via {@see Command::env()} or {@see Command::envs()}. In addition, it will
* prevent the spawned child process from inheriting that environment
* variable from its parent process.
*
* After calling {@see Command::envRemove()}, the value associated with its
* key from {@see Command::getEnvs()} will be `NULL`.
*
* To clear all explicitly set environment variables and disable all
* environment variable inheritance, you can use {@see Command::envClear()}.
*/
public function envRemove(string $key): static
{
$this->environment[$key] = null;
return $this;
}
/**
* Clears all explicitly set environment variables and prevents inheriting
* any parent process environment variables.
*
* This method will remove all explicitly added environment variables set
* via {@see Command::env()} or {@see Command::envs()}. In addition, it will
* prevent the spawned child process from inheriting any environment
* variable from its parent process.
*
* After calling {@see Command::envClear()}, the array from
* {@see Command::getEnvs()} will be empty.
*
* You can use {@see Command::envRemove()} to clear a single mapping.
*/
public function envClear(): static
{
$this->environmentInherit = false;
$this->environment = [];
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 processs 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 +203,12 @@ final class Command implements Stringable
return $this;
}
/**
* Configuration for the child processs 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 +221,12 @@ final class Command implements Stringable
return $this;
}
/**
* Configuration for the child processs 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 +253,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 +275,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 +288,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 +311,49 @@ 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.
*
* Environment variables explicitly set using {@see Command::env(),
* {@see Command::envs()}, and {@see Command::envRemove} can be retrieved
* with this method.
*
* Note that this output does not include environment variables inherited
* from the parent process.
*/
public function getEnvs(): array
{
return $this->environment ?? [];
}
/**
* 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 +373,32 @@ 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');
$environment = $this->environment;
if (is_array($environment) && $this->environmentInherit) {
foreach (getenv() as $key => $val) {
if (!array_key_exists($key, $environment)) {
$environment[$key] = $val;
}
}
}
$stdin = array_key_exists(0, $pipes)
? new ChildStdin($pipes[0])
: null;
$proc = proc_open($command, $descriptorSpec, $pipes, $this->cwd, $environment);
if ($proc === false) {
throw new CommandException(sprintf(
'Program "%s" failed to start',
$this->program,
));
}
$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);
}
}

View File

@@ -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;
}
}

View File

@@ -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