fix!: handle errors properly

This commit is contained in:
2026-02-14 20:49:08 +01:00
parent 5797059008
commit bd065eab32
6 changed files with 103 additions and 39 deletions

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
use InvalidArgumentException;
/** /**
* Representation of a running or exited child process. * Representation of a running or exited child process.
*/ */
@@ -26,6 +28,11 @@ final class Child
*/ */
private ?ExitStatus $status = null; private ?ExitStatus $status = null;
/**
* The child's output.
*/
private ?Output $output = null;
/** /**
* The handle for writing to the childs standard input (stdin), if it has * The handle for writing to the childs standard input (stdin), if it has
* been captured. * been captured.
@@ -45,13 +52,17 @@ final class Child
public readonly ?ChildStderr $stderr; public readonly ?ChildStderr $stderr;
/** /**
* @param resource $process The child's process handle. * @param resource $process The child's process resource handle.
* @param array<int, resource> $pipes File pointers. * @param array<int, resource> $pipes
* The indexed array of file pointers that set by {@see \proc_open()}.
*
* @throws InvalidArgumentException
* When the given process is not a process resource.
*/ */
public function __construct(mixed $process, array $pipes) public function __construct(mixed $process, array $pipes)
{ {
if (get_resource_type($process) !== 'process') { if (get_resource_type($process) !== 'process') {
throw new ChildException('invalid resource type: not a process'); throw new InvalidArgumentException('resource is not a process');
} }
$this->process = $process; $this->process = $process;
@@ -89,8 +100,6 @@ final class Child
* waiting. This helps avoid deadlock: it ensures that the child does not * 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 * block waiting for input from the parent, while the parent waits for the
* child to exit. * child to exit.
*
* @throws ChildException If the resource was already closed
*/ */
public function wait(): ExitStatus public function wait(): ExitStatus
{ {
@@ -111,7 +120,7 @@ final class Child
proc_close($this->process); proc_close($this->process);
return $this->status = new ExitStatus( return $this->status = new ExitStatus(
$status['exitcode'], $status['exitcode'] < 0 ? null : $status['exitcode'],
$status['signaled'] ? $status['termsig'] : null, $status['signaled'] ? $status['termsig'] : null,
$status['stopped'] ? $status['stopsig'] : null, $status['stopped'] ? $status['stopsig'] : null,
); );
@@ -120,6 +129,8 @@ final class Child
/** /**
* Simultaneously waits for the child to exit and collect all remaining * Simultaneously waits for the child to exit and collect all remaining
* output on the stdout/stderr handles, returning an {@see Output} instance. * output on the stdout/stderr handles, returning an {@see Output} instance.
* 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 * 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 * waiting. This helps avoid deadlock: it ensures that the child does not
@@ -131,12 +142,13 @@ final class Child
* parent and child. Use the `stdout` and `stderr` functions of {@see * parent and child. Use the `stdout` and `stderr` functions of {@see
* Command}, respectively. * Command}, respectively.
* *
* @throws ChildException If the resource was already closed * @throws ChildException
* @throws StreamException
*/ */
public function waitWithOutput(): Output public function waitWithOutput(): Output
{ {
if (!is_resource($this->process)) { if ($this->output) {
throw new ChildException('Resource was already closed'); return $this->output;
} }
// Avoid possible deadlock before waiting. // Avoid possible deadlock before waiting.
@@ -146,21 +158,29 @@ final class Child
$stderr = $this->stderr?->getContents(); $stderr = $this->stderr?->getContents();
$status = $this->wait(); $status = $this->wait();
return new Output($stdout, $stderr, $status); return $this->output = new Output($stdout, $stderr, $status);
} }
/** /**
* Forces the child process to exit. * Forces the child process to exit.
* *
* This is equivalent to sending a SIGKILL. * This is equivalent to sending a SIGKILL.
*
* @throws ChildException
*/ */
public function kill(): bool public function kill(): void
{ {
if (!is_resource($this->process)) { if ($this->status) {
return true; return;
} }
return proc_terminate($this->process, 9); try {
set_error_handler(ChildException::handleError(...));
$success = proc_terminate($this->process, 9);
assert($success);
} finally {
restore_error_handler();
}
} }
public function __destruct() public function __destruct()

View File

@@ -4,5 +4,23 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
class ChildException extends CommandException /**
{} * A PHP error occured during an operation on a process resource.
*/
final class ChildException extends CommandException
{
public static function handleError(
int $severity,
string $message,
string $filename,
int $line,
): never {
throw new self(
message: $message,
code: 0,
severity: $severity,
filename: $filename,
line: $line,
);
}
}

View File

@@ -278,6 +278,8 @@ final class Command implements Stringable
* Executes the command as a child process, returning a handle to it. * Executes the command as a child process, returning a handle to it.
* *
* By default, stdin, stdout and stderr are inherited from the parent. * By default, stdin, stdout and stderr are inherited from the parent.
*
* @throws ChildException
*/ */
public function spawn(): Child public function spawn(): Child
{ {
@@ -299,6 +301,8 @@ final class Command implements Stringable
* collecting its status. * collecting its status.
* *
* By default, stdin, stdout and stderr are inherited from the parent. * By default, stdin, stdout and stderr are inherited from the parent.
*
* @throws ChildException
*/ */
public function status(): ExitStatus public function status(): ExitStatus
{ {
@@ -311,6 +315,8 @@ final class Command implements Stringable
* *
* By default, stdout and stderr are captured (and used to provide the * By default, stdout and stderr are captured (and used to provide the
* resulting output). Stdin is not inherited from the parent. * resulting output). Stdin is not inherited from the parent.
*
* @throws ChildException
*/ */
public function output(): Output public function output(): Output
{ {
@@ -396,13 +402,6 @@ final class Command implements Stringable
} }
} }
if (!is_executable($program)) {
throw new CommandException(sprintf(
'Program "%s" is not executable',
$program,
));
}
// Handle environment inheritance. // Handle environment inheritance.
$environment = $this->environment; $environment = $this->environment;
if (is_array($environment) && $this->environmentInherit) { if (is_array($environment) && $this->environmentInherit) {
@@ -414,7 +413,7 @@ final class Command implements Stringable
} }
try { try {
set_error_handler(CommandException::handleError(...)); set_error_handler(ChildException::handleError(...));
$proc = proc_open( $proc = proc_open(
[$program, ...$this->args], [$program, ...$this->args],
$descriptorSpec, $descriptorSpec,

View File

@@ -4,16 +4,9 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
use RuntimeException; use ErrorException;
class CommandException extends RuntimeException /**
{ *
public static function handleError(int $errno, string $errstr, string $file, int $line): never */
{ abstract class CommandException extends ErrorException {}
$exception = new self($errstr);
$exception->file = $file;
$exception->line = $line;
throw $exception;
}
}

View File

@@ -27,7 +27,9 @@ final readonly class ExitStatus
*/ */
public function success(): bool public function success(): bool
{ {
return $this->code === 0; return $this->code === 0
&& $this->signal === null
&& $this->stoppedSignal === null;
} }
/** /**

View File

@@ -5,6 +5,38 @@ declare(strict_types=1);
namespace Nih\CommandBuilder; namespace Nih\CommandBuilder;
/** /**
* An error prevented reading from or writing to a stream resource. * A PHP error occured during an operation on a stream resource.
*/ */
class StreamException extends CommandException {} final class StreamException extends CommandException
{
/**
* A note on handling IO errors by setting an error handler before each
* operation: Yes, this is slower, but not by much (1.5x).
*
* However, it is even slower if wrapped with another function, so it is better
* to do this inline every time before the function (e.g. fopen) is called.
*
* ```php
* try {
* set_error_handler(CommandException::handleError(...));
* $handle = fopen('foo', 'r');
* } finally {
* restore_error_handler();
* }
* ```
*/
public static function handleError(
int $severity,
string $message,
string $filename,
int $line,
): never {
throw new self(
message: $message,
code: 0,
severity: $severity,
filename: $filename,
line: $line,
);
}
}