fix!: handle php errors with custom error handling

This commit changes the api of the stream classes, since indication of
success or failure is no longer necessary.
This commit is contained in:
2026-02-13 23:26:32 +01:00
parent 3a5cad161d
commit 5797059008
14 changed files with 232 additions and 135 deletions

View File

@@ -50,6 +50,10 @@ final class Child
*/
public function __construct(mixed $process, array $pipes)
{
if (get_resource_type($process) !== 'process') {
throw new ChildException('invalid resource type: not a process');
}
$this->process = $process;
$status = proc_get_status($this->process);
@@ -94,10 +98,6 @@ final class Child
return $this->status;
}
if (!is_resource($this->process)) {
throw new ChildException('Resource was already closed');
}
// Avoid possible deadlock before waiting.
$this->stdin?->close();
@@ -165,7 +165,7 @@ final class Child
public function __destruct()
{
if (is_resource($this->process)) {
if (get_resource_type($this->process) === 'process') {
proc_close($this->process);
}
}

View File

@@ -8,7 +8,7 @@ use Override;
final class ChildStderr implements StdioInterface
{
use StreamReadable;
use StreamReadTrait;
#[Override]
public function getDescriptionSpec(int $fd): mixed

View File

@@ -8,7 +8,7 @@ use Override;
final class ChildStdin implements StdioInterface
{
use StreamWritable;
use StreamWriteTrait;
#[Override]
public function getDescriptionSpec(int $fd): mixed

View File

@@ -8,7 +8,7 @@ use Override;
final class ChildStdout implements StdioInterface
{
use StreamReadable;
use StreamReadTrait;
#[Override]
public function getDescriptionSpec(int $fd): mixed

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Nih\CommandBuilder;
use InvalidArgumentException;
use Override;
use Stringable;
use ValueError;
@@ -202,10 +203,19 @@ final class Command implements Stringable
*
* Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output.
*
* @param StdioInterface|resource $stdin
*
* @throws InvalidArgumentException
* When the provided stdin handle is neither a StdioInterface nor a live
* stream resource.
*/
public function stdin(StdioInterface $stdin): static
public function stdin(mixed $stdin): static
{
$this->stdin = $stdin;
$this->stdin = $stdin instanceof StdioInterface
? $stdin
: Stdio::stream($stdin);
return $this;
}
@@ -214,10 +224,19 @@ final class Command implements Stringable
*
* Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output.
*
* @param StdioInterface|resource $stdout
*
* @throws InvalidArgumentException
* When the provided stdout handle is neither a StdioInterface nor a live
* stream resource.
*/
public function stdout(StdioInterface $stdout): static
public function stdout(mixed $stdout): static
{
$this->stdout = $stdout;
$this->stdout = $stdout instanceof StdioInterface
? $stdout
: Stdio::stream($stdout);
return $this;
}
@@ -226,10 +245,19 @@ final class Command implements Stringable
*
* Defaults to inherit when used with spawn or status, and defaults to piped
* when used with output.
*
* @param StdioInterface|resource $stderr
*
* @throws InvalidArgumentException
* When the provided stderr handle is neither a StdioInterface nor a live
* stream resource.
*/
public function stderr(StdioInterface $stderr): static
public function stderr(mixed $stderr): static
{
$this->stderr = $stderr;
$this->stderr = $stderr instanceof StdioInterface
? $stderr
: Stdio::stream($stderr);
return $this;
}
@@ -385,21 +413,21 @@ final class Command implements Stringable
}
}
$proc = proc_open(
[$program, ...$this->args],
$descriptorSpec,
$pipes,
$this->cwd,
$environment,
);
if ($proc === false) {
throw new CommandException(sprintf(
'Program "%s" failed to start',
$this->program,
));
try {
set_error_handler(CommandException::handleError(...));
$proc = proc_open(
[$program, ...$this->args],
$descriptorSpec,
$pipes,
$this->cwd,
$environment,
);
} finally {
restore_error_handler();
}
assert($proc !== false);
return new Child($proc, $pipes);
}
}

View File

@@ -7,4 +7,13 @@ namespace Nih\CommandBuilder;
use RuntimeException;
class CommandException extends RuntimeException
{}
{
public static function handleError(int $errno, string $errstr, string $file, int $line): never
{
$exception = new self($errstr);
$exception->file = $file;
$exception->line = $line;
throw $exception;
}
}

View File

@@ -100,22 +100,12 @@ abstract class Stdio
*
* @param resource $stream
*
* @throws InvalidArgumentException When stream is not a live stream resource
* @throws InvalidArgumentException When stream is not a stream resource
*/
public static function stream($stream): StdioInterface
{
$isResource = is_resource($stream)
|| ($stream !== null
&& !is_scalar($stream)
&& !is_array($stream)
&& !is_object($stream));
if (!$isResource) {
throw new InvalidArgumentException('not a resource');
}
if (get_resource_type($stream) !== 'stream') {
throw new InvalidArgumentException('resource is not a stream or was closed');
throw new InvalidArgumentException('resource is not a stream');
}
return new class($stream) implements StdioInterface {

View File

@@ -14,7 +14,7 @@ use Stringable;
interface StdioInterface
{
/**
* @return resource|array{0: 'file', 1: string, 2: string}|array{0: 'piped', 1: string}
* @return resource|array{0: 'file', 1: string, 2: string}|array{0: 'pipe', 1: string}
*/
public function getDescriptionSpec(int $fd): mixed;
}

10
src/StreamException.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
/**
* An error prevented reading from or writing to a stream resource.
*/
class StreamException extends CommandException {}

52
src/StreamReadTrait.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
trait StreamReadTrait
{
use StreamTrait;
/**
* @throws StreamException
*/
public function read(int $length): ?string
{
if (get_resource_type($this->stream) !== 'stream') {
throw new StreamException('the stream was closed');
}
try {
set_error_handler(StreamException::handleError(...));
$bytes = fread($this->stream, $length);
} finally {
restore_error_handler();
}
assert($bytes !== false);
return $bytes;
}
/**
* @throws StreamException
*/
public function getContents(?int $length = null, int $offset = -1): string
{
if (get_resource_type($this->stream) !== 'stream') {
throw new StreamException('the stream was closed');
}
try {
set_error_handler(StreamException::handleError(...));
$contents = stream_get_contents($this->stream, $length, $offset);
} finally {
restore_error_handler();
}
assert($contents !== false);
return $contents;
}
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
trait StreamReadable
{
/**
* @param resource $stream
*/
public function __construct(public readonly mixed $stream)
{
}
public function read(int $length): ?string
{
if (!is_resource($this->stream)) {
throw new CommandException('Cannot read from closed stream');
}
return fread($this->stream, $length) ?: null;
}
public function getContents(?int $length = null, int $offset = -1): ?string
{
if (!is_resource($this->stream)) {
throw new CommandException('Cannot read from closed stream');
}
$contents = stream_get_contents($this->stream, $length, $offset);
return $contents === false
? null
: $contents;
}
public function close(): bool
{
return is_resource($this->stream)
? fclose($this->stream)
: true;
}
public function __destruct()
{
$this->close();
}
}

52
src/StreamTrait.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use InvalidArgumentException;
trait StreamTrait
{
/**
* @param resource $stream
*
* @throws InvalidArgumentException When stream is not a stream
*/
public function __construct(private readonly mixed $stream)
{
if (get_resource_type($stream) !== 'stream') {
throw new InvalidArgumentException('resource is not a stream');
}
}
/**
* Close the stream.
*
* Noop if the stream was already closed.
*
* @throws StreamException
*/
public function close(): void
{
if (get_resource_type($this->stream) !== 'stream') {
return;
}
try {
set_error_handler(StreamException::handleError(...));
$success = fclose($this->stream);
} finally {
restore_error_handler();
}
assert($success);
}
public function __destruct()
{
if (get_resource_type($this->stream) === 'stream') {
fclose($this->stream);
}
}
}

View File

@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
trait StreamWritable
{
/**
* @param resource $stream
*/
public function __construct(public readonly mixed $stream)
{
}
public function write(string $data, ?int $length = null): ?int
{
if (!is_resource($this->stream)) {
throw new CommandException('Cannot write to closed stream');
}
$bytes = fwrite($this->stream, $data, $length) ?: null;
return $bytes === false ? null : $bytes;
}
public function flush(): bool
{
if (!is_resource($this->stream)) {
throw new CommandException('Cannot flush closed stream');
}
return fflush($this->stream);
}
public function close(): bool
{
return is_resource($this->stream)
? fclose($this->stream)
: true;
}
public function __destruct()
{
$this->close();
}
}

50
src/StreamWriteTrait.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
trait StreamWriteTrait
{
use StreamTrait;
/**
* @throws StreamException
*/
public function write(string $data, ?int $length = null): int
{
if (get_resource_type($this->stream) !== 'stream') {
throw new StreamException('the stream was closed');
}
try {
set_error_handler(StreamException::handleError(...));
$bytes = fwrite($this->stream, $data, $length);
} finally {
restore_error_handler();
}
assert($bytes !== false);
return $bytes;
}
/**
* @throws StreamException
*/
public function flush(): void
{
if (get_resource_type($this->stream) !== 'stream') {
throw new StreamException('the stream was closed');
}
try {
set_error_handler(StreamException::handleError(...));
$success = fflush($this->stream);
} finally {
restore_error_handler();
}
assert($success);
}
}