fix: handle stdio properly

This commit is contained in:
2025-08-17 13:55:24 +02:00
parent 4ae0545ab4
commit 5f6f5bf04b
15 changed files with 311 additions and 120 deletions

View File

@@ -11,7 +11,7 @@ use Nih\CommandBuilder\Stdio;
require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/vendor/autoload.php';
$child = (new Command('/usr/bin/cat')) $child = (new Command('/usr/bin/cat'))
->stdin(Stdio::piped('r')) ->stdin(Stdio::piped())
->spawn(shell: false); ->spawn(shell: false);
$child->stdin?->write('Hello, this is pretty cool.'); $child->stdin?->write('Hello, this is pretty cool.');

View File

@@ -1,26 +0,0 @@
<?php
use Nih\CommandBuilder\Command;
use Nih\CommandBuilder\Stdio;
require_once __DIR__ . '/vendor/autoload.php';
$output = (new Command('echo'))
->arg('-n')
->arg('-')
->arg("./ \$wow '''/stdout.txt")
->stderr(Stdio::null())
->output();
var_dump($output);
$child = (new Command('/usr/bin/cat'))
->stdin(Stdio::piped('r'))
->spawn(shell: false);
$child->stdin?->write('Hello, this is pretty cool.');
$child->stdin?->close();
$output = $child->output();
var_dump($output);

24
examples/hello-world.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
use Nih\CommandBuilder\Command;
require_once __DIR__ . '/../vendor/autoload.php';
$output = (new Command('echo'))
->arg('Hello, World!')
->output();
var_dump($output);
// object(Nih\CommandBuilder\Output)#9 (3) {
// ["stdout"]=>
// string(14) "Hello, World!
// "
// ["stderr"]=>
// string(0) ""
// ["code"]=>
// object(Nih\CommandBuilder\ExitStatus)#8 (1) {
// ["code"]=>
// int(0)
// }
// }

17
examples/plumbing.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
use Nih\CommandBuilder\Command;
use Nih\CommandBuilder\Stdio;
require_once __DIR__ . '/../vendor/autoload.php';
$echo = (new Command('echo'))
->arg('Hello, World!')
->stdout(Stdio::piped())
->spawn();
$cat = (new Command('cat'))
->stdin(Stdio::stream($echo->stdout))
->status();
// Prints "Hello, World!\n"

35
examples/stdio.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
use Nih\CommandBuilder\Command;
use Nih\CommandBuilder\Stdio;
require_once __DIR__ . '/../vendor/autoload.php';
$child = (new Command('cat'))
->stdin(Stdio::piped())
->stdout(Stdio::piped())
->spawn();
$child->stdin?->write('Hello, World!');
$output = $child->waitWithOutput();
var_dump($output);
// object(Nih\CommandBuilder\Output)#6 (3) {
// ["stdout"]=>
// string(13) "Hello, World!"
// ["stderr"]=>
// NULL
// ["code"]=>
// object(Nih\CommandBuilder\ExitStatus)#4 (1) {
// ["code"]=>
// int(0)
// }
// }
(new Command('echo'))
->arg('Hello, World!')
->stdout(Stdio::inherit())
->status();
// Hello, World!

View File

@@ -19,31 +19,55 @@ final class Child
public function id(): int public function id(): int
{ {
if (!is_resource($this->proc)) {
throw new ChildException('Resource was already closed');
}
$status = proc_get_status($this->proc); $status = proc_get_status($this->proc);
return $status['pid']; return $status['pid'];
} }
public function output(): Output public function waitWithOutput(): Output
{ {
if (!is_resource($this->proc)) {
throw new ChildException('Resource was already closed');
}
// Avoid possible deadlock before waiting.
$this->stdin?->close();
$stdout = $this->stdout?->getContents(); $stdout = $this->stdout?->getContents();
$stderr = $this->stderr?->getContents(); $stderr = $this->stderr?->getContents();
$code = proc_close($this->proc); $status = new ExitStatus(proc_close($this->proc));
return new Output($stdout, $stderr, $code); return new Output($stdout, $stderr, $status);
} }
public function status(): int public function wait(): ExitStatus
{ {
return proc_close($this->proc); if (!is_resource($this->proc)) {
} throw new ChildException('Resource was already closed');
}
public function wait(): void // Avoid possible deadlock before waiting.
{ $this->stdin?->close();
proc_close($this->proc);
return new ExitStatus(proc_close($this->proc));
} }
public function kill(): bool public function kill(): bool
{ {
if (!is_resource($this->proc)) {
throw new ChildException('Resource was already closed');
}
return proc_terminate($this->proc, 10); return proc_terminate($this->proc, 10);
} }
public function __destruct()
{
if (is_resource($this->proc)) {
proc_close($this->proc);
}
}
} }

8
src/ChildException.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
class ChildException extends CommandException
{}

View File

@@ -6,9 +6,10 @@ namespace Nih\CommandBuilder;
use RuntimeException; use RuntimeException;
use Stringable; use Stringable;
use TypeError;
use ValueError; use ValueError;
final class Command final class Command implements Stringable
{ {
public readonly string $command; public readonly string $command;
@@ -48,7 +49,7 @@ final class Command
return $this; return $this;
} }
public function cwd(string|Stringable $cwd): static public function currentDir(string|Stringable $cwd): static
{ {
$this->cwd = (string) $cwd; $this->cwd = (string) $cwd;
return $this; return $this;
@@ -72,43 +73,118 @@ final class Command
return $this; return $this;
} }
public function stdin(Stdio $in): static public function stdin(Stdio $stdin): static
{ {
$this->stdin = $in; $stdin = match ($stdin->type) {
Stdio::INHERIT => Stdio::stream(STDIN),
Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'r']),
default => $stdin,
};
$this->stdin = $stdin;
return $this; return $this;
} }
public function stdout(Stdio $out): static public function stdout(Stdio $stdout): static
{ {
$this->stdout = $out; $stdout = match ($stdout->type) {
Stdio::INHERIT => Stdio::stream(STDOUT),
Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'w']),
default => $stdout,
};
$this->stdout = $stdout;
return $this; return $this;
} }
public function stderr(Stdio $err): static public function stderr(Stdio $stderr): static
{ {
$this->stderr = $err; $stderr = match ($stderr->type) {
Stdio::INHERIT => Stdio::stream(STDERR),
Stdio::PIPE => new Stdio(Stdio::PIPE, ['pipe', 'w']),
default => $stderr,
};
$this->stderr = $stderr;
return $this; return $this;
} }
// TODO: Allow capturing arbitrary descriptors (proc_open supports this)?
// public function descriptor(int $fd, Stdio $stdio): static
// {
// match ($fd) {
// 0 => $this->stdin($stdio),
// 1 => $this->stdout($stdio),
// 2 => $this->stderr($stdio),
// default => $this->fd[$fd] = $stdio,
// };
//
// return $this;
// }
/** /**
* @param bool $shell Run the command with or without a shell * @param bool $shell Run the command with or without a shell
*/ */
public function spawn(bool $shell = true): Child public function spawn(bool $shell = true): Child
{ {
$fd[0] = $this->stdin instanceof Stdio return $this->spawnWithDescriptorSpec($shell, [
? $this->stdin->getDescriptorSpec() $this->stdin instanceof Stdio
: ['pipe', 'r']; ? $this->stdin->descriptorSpec
: STDIN,
$this->stdout instanceof Stdio
? $this->stdout->descriptorSpec
: STDOUT,
$this->stderr instanceof Stdio
? $this->stderr->descriptorSpec
: STDERR,
]);
}
$fd[1] = $this->stdout instanceof Stdio /**
? $this->stdout->getDescriptorSpec() * @param bool $shell Run the command with or without a shell
: ['pipe', 'w']; */
public function status(bool $shell = true): ExitStatus
{
return $this->spawn($shell)->wait();
}
$fd[2] = $this->stderr instanceof Stdio /**
? $this->stderr->getDescriptorSpec() * @param bool $shell Run the command with or without a shell
: ['pipe', 'w']; */
public function output(bool $shell = true): Output
{
return $this->spawnWithDescriptorSpec($shell, [
$this->stdin instanceof Stdio
? $this->stdin->descriptorSpec
: ['pipe', 'r'],
$this->stdout instanceof Stdio
? $this->stdout->descriptorSpec
: ['pipe', 'w'],
$this->stderr instanceof Stdio
? $this->stderr->descriptorSpec
: ['pipe', 'w'],
])->waitWithOutput();
}
public function __toString(): string
{
return implode(' ', array_map(escapeshellarg(...), $this->args));
}
private function spawnWithDescriptorSpec(bool $shell, array $descriptorSpec): Child
{
foreach ($descriptorSpec as $descriptor => $spec) {
if (!is_array($spec) && !is_resource($spec)) {
throw new CommandException(sprintf(
'Descriptor %d is not a valid stream resource: %s',
$descriptor,
get_debug_type($spec),
));
}
}
if ($shell) { if ($shell) {
$command = implode(' ', array_map(escapeshellarg(...), $this->args)); $command = (string) $this;
} else if (is_executable($this->command)) { } else if (is_executable($this->command)) {
$command = $this->args; $command = $this->args;
} else { } else {
@@ -118,9 +194,10 @@ final class Command
)); ));
} }
$proc = proc_open($command, $fd, $pipes, $this->cwd, $this->envs); $proc = proc_open($command, $descriptorSpec, $pipes, $this->cwd, $this->envs);
if ($proc === false) { if ($proc === false) {
throw new RuntimeException(); throw new RuntimeException('Failed proc_open');
} }
$stdin = array_key_exists(0, $pipes) $stdin = array_key_exists(0, $pipes)
@@ -137,28 +214,4 @@ final class Command
return new Child($stdin, $stdout, $stderr, $proc); return new Child($stdin, $stdout, $stderr, $proc);
} }
/**
* @param bool $shell Run the command with or without a shell
*/
public function output(bool $shell = true): Output
{
return $this->spawn($shell)->output();
}
/**
* @param bool $shell Run the command with or without a shell
*/
public function status(bool $shell = true): int
{
return $this->spawn($shell)->status();
}
/**
* @param bool $shell Run the command with or without a shell
*/
public function wait(bool $shell = true): void
{
$this->spawn($shell)->wait();
}
} }

View File

@@ -6,5 +6,5 @@ namespace Nih\CommandBuilder;
use RuntimeException; use RuntimeException;
final class CommandException extends RuntimeException class CommandException extends RuntimeException
{} {}

18
src/ExitStatus.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
final class ExitStatus
{
public function __construct(
public readonly int $code,
) {
}
public function success(): bool
{
return $this->code === 0;
}
}

View File

@@ -9,7 +9,7 @@ final class Output
public function __construct( public function __construct(
public readonly ?string $stdout, public readonly ?string $stdout,
public readonly ?string $stderr, public readonly ?string $stderr,
public readonly int $code, public readonly ExitStatus $status,
) { ) {
} }
} }

View File

@@ -6,27 +6,55 @@ namespace Nih\CommandBuilder;
use Stringable; use Stringable;
abstract class Stdio final class Stdio
{ {
abstract public function getDescriptorSpec(): array; public const INHERIT = 0;
public const PIPE = 1;
public const FILE = 2;
public const STREAM = 3;
public static function file(string|Stringable $file, string $mode): self /**
{ * @param null
return new StdioFile((string) $file, $mode); * |resource
* |array{0: 'file', 1: string, 2: string}
* |array{0: 'pipe',1: string} $descriptorSpec
*/
public function __construct(
public readonly int $type,
public readonly mixed $descriptorSpec,
) {
} }
public static function piped(string $mode): self public static function inherit(): self
{ {
return new StdioPiped($mode); return new self(self::INHERIT, null);
} }
public static function inherit(): null public static function piped(): self
{ {
return null; return new self(self::PIPE, ['pipe', 'r']);
} }
public static function null(): self public static function null(): self
{ {
return new StdioFile('/dev/null', 'a+'); return self::file('/dev/null', 'a+');
} }
public static function file(string|Stringable $file, string $mode): self
{
return new self(self::FILE, ['file', (string) $file, $mode]);
}
/**
* @param resource|StreamReadable|StreamWritable $stream
*/
public static function stream($stream): self
{
if (is_object($stream)) {
$stream = $stream->stream;
}
return new self(self::STREAM, $stream);
}
} }

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Nih\CommandBuilder;
use ValueError;
final class StdioFile extends Stdio
{
public function __construct(
public readonly string $file,
public readonly string $mode,
) {
match ($mode) {
'r', 'w', 'a', 'x', 'r+', 'w+', 'a+', 'x+' => null,
default => throw new ValueError('Invalid mode: ' . $mode),
};
}
public function getDescriptorSpec(): array
{
return ['file', $this->file, $this->mode];
}
}

View File

@@ -9,20 +9,40 @@ trait StreamReadable
/** /**
* @param resource $stream * @param resource $stream
*/ */
public function __construct(private $stream) public function __construct(public readonly mixed $stream)
{ {
} }
public function read(int $length): ?string 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; return fread($this->stream, $length) ?: null;
} }
public function getContents(?int $length = null, int $offset = -1): ?string 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); $contents = stream_get_contents($this->stream, $length, $offset);
return $contents === false return $contents === false
? null ? null
: $contents; : $contents;
} }
public function close(): bool
{
return is_resource($this->stream)
? fclose($this->stream)
: true;
}
public function __destruct()
{
$this->close();
}
} }

View File

@@ -9,23 +9,38 @@ trait StreamWritable
/** /**
* @param resource $stream * @param resource $stream
*/ */
public function __construct(private $stream) public function __construct(public readonly mixed $stream)
{ {
} }
public function write(string $data, ?int $length = null): ?int 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; $bytes = fwrite($this->stream, $data, $length) ?: null;
return $bytes === false ? null : $bytes; return $bytes === false ? null : $bytes;
} }
public function flush(): bool public function flush(): bool
{ {
if (!is_resource($this->stream)) {
throw new CommandException('Cannot flush closed stream');
}
return fflush($this->stream); return fflush($this->stream);
} }
public function close(): bool public function close(): bool
{ {
return fclose($this->stream); return is_resource($this->stream)
? fclose($this->stream)
: true;
}
public function __destruct()
{
$this->close();
} }
} }