diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5eb89b..4d7ff93 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,11 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] php: ['8.2', '8.3', '8.4', '8.5'] + include: + - os: macos-latest + php: '8.5' name: PHP ${{ matrix.php }} · ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c2ddede --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +## [Unreleased] + +### Added + +- Runnable quickstart, interactive terminal relay and multiple-session examples. +- Interactive relay coverage for input, initial geometry, live resizing, + Ctrl-C, large final output, signal cleanup, terminal restoration and exit after terminal EOF. +- Contributor instructions and practical installation/troubleshooting guidance. +- PHP 8.5 CI coverage and explicit PHPStan, Pint and Composer validation. + +### Fixed + +- Close both the original PTY master and the PHP stream duplicate; keep cleanup + idempotent even when callers close the stream themselves. +- Close inherited php-pty sessions in new children without retaining abandoned + sessions in the parent. +- Resolve executables before allocating descriptors, preserve executable + symlinks and reject invalid working directories and terminal dimensions. +- Exit failed children without running inherited PHP shutdown callbacks. +- Retry interrupted waits, preserve unknown externally collected statuses, + validate timeouts and use monotonic deadlines. +- Verify real child responses and payload hashes instead of terminal echo or + only the write count. + +### Changed + +- CI tests PHP 8.2–8.5 on Linux and PHP 8.5 on macOS ARM64, with one additional + quality job: six jobs total. The minimum PHP requirement remains `^8.2`. +- Move detailed ABI and partial-write findings into implementation notes. + +## [0.1.0] - 2026-08-15 + +- Initial public release as `croustibat/php-pty`. +- FFI-backed PTY creation, controlling terminals, resizing, non-blocking streams, + bounded writes and process lifecycle methods for macOS and Linux. + +[Unreleased]: https://github.com/croustibat/php-pty/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/croustibat/php-pty/tree/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f25187d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Use PHP 8.2 or newer on macOS or Linux, with `ffi`, `pcntl` and `posix` +enabled for the CLI. The examples additionally use the system `stty` command. + +```bash +composer install +./vendor/bin/pest +./vendor/bin/phpstan analyse +./vendor/bin/pint --test +composer validate --strict +``` + +Run tests where real pseudo-terminals and `/dev/tty` are accessible. A sandbox +that denies terminal access can fail integration tests despite the code being +correct. FFI can be enabled for a command with `php -d ffi.enable=1`. + +CI runs the integration suite on PHP 8.2, 8.3, 8.4 and 8.5 under Linux, plus +PHP 8.5 on macOS ARM64. The macOS job guards the variadic `ioctl` ABI; it must +remain on Apple Silicon. A separate job runs PHPStan, Pint and Composer +validation. The package continues to require PHP `^8.2`. + +For changes to process handling, exercise the child itself: terminal echo is +not proof that the program read its input. Signal readiness explicitly, drain +output before waiting, keep deadlines bounded and clean up sessions in +`finally`. The interactive example tests wrap the relay in another real PTY +and verify terminal settings before and after execution. + +`phpstan/` contains analysis-only declarations for FFI. Never autoload them at +runtime. Keep their declared methods and fields consistent with `Pty::CDEF`. + +Describe the problem, expected behaviour, PHP version, OS and architecture +when opening an issue. Include a minimal command or script that reproduces it. +For vulnerabilities, follow [SECURITY.md](SECURITY.md) instead of opening a +public issue. Record user-visible changes under Unreleased in +[CHANGELOG.md](CHANGELOG.md). diff --git a/README.md b/README.md index 7e70de4..f703e76 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,58 @@ Just `ext-ffi` and `ext-pcntl`. macOS and Linux. composer require croustibat/php-pty ``` -> The package is `croustibat/php-pty`; the namespace is `Croustibat\Pty\`. -> Hyphens are not legal in PHP identifiers, so the two never match exactly. +The namespace is `Croustibat\Pty\`. The package requires PHP CLI with FFI, +pcntl and posix; it has no runtime Composer dependencies. -```php -use Croustibat\Pty\Pty; +## Try it -$session = Pty::spawn(['claude', '--resume'], rows: 30, cols: 120); +From a checkout, install dependencies and run the quickstart: -$session->write("hello\n"); -$session->resize(40, 100); // real TIOCSWINSZ, real SIGWINCH -echo $session->read(); +```bash +git clone https://github.com/croustibat/php-pty.git +cd php-pty +composer install +php examples/quickstart.php +``` + +It prints `30 120`: the terminal dimensions reported by the child process. +No API key, external service or additional application is needed. + +Then open an interactive shell: + +```bash +php examples/interactive.php +``` + +Inside it, run `stty size`, resize your terminal window and run `stty size` +again. Type `exit` or press Ctrl-D at an empty shell prompt to leave. Ctrl-C +is forwarded through the PTY to the child terminal's foreground process group. +Your original terminal settings are restored when the relay finishes. + +Pass a command as separate arguments after `--`: -$session->stream(); // non-blocking, for stream_select() -$session->wait(); // exit code +```bash +php examples/interactive.php -- /bin/sh +php examples/interactive.php -- top +php examples/multiple-sessions.php ``` +| Example | What it demonstrates | +|---|---| +| [quickstart.php](examples/quickstart.php) | Spawn with a known size, drain output and collect the exit code | +| [interactive.php](examples/interactive.php) | Keyboard relay, live resizing, bounded queues, partial writes and terminal restoration | +| [multiple-sessions.php](examples/multiple-sessions.php) | One `stream_select()` loop reading two independently finishing commands | + +The interactive relay requires a terminal on both STDIN and STDOUT and the +system `stty` command. It returns the child's exit code. It handles external +SIGINT, SIGTERM, SIGHUP and SIGQUIT with cleanup; SIGKILL cannot be intercepted. +The multiple-session example labels each output line with `fast` or `slow`; +ordering between processes is intentionally not guaranteed. + +When installed with Composer in an application, the examples can also be run +under `vendor/croustibat/php-pty/examples/`. Run them as standalone CLI scripts. +They are reference implementations to adapt, not a public event-loop API. + ## Why PHP can already reach a pty through `proc_open()` with `['pty']` descriptors — @@ -64,6 +100,19 @@ a session daemon. This is the primitive, not the product. - `ext-ffi`, `ext-pcntl`, `ext-posix` - macOS or Linux +## Troubleshooting + +- **Missing extension:** inspect `php -m` and `php --ini` for the CLI binary + actually running the example. Enabling an extension only in FPM is insufficient. +- **FFI disabled:** try `php -d ffi.enable=1 examples/quickstart.php`. The + extension must still be installed. Do not run this library in a web request. +- **Binding fails on Linux:** check the installed C runtime and availability + of `openpty` and `login_tty`. CI covers Ubuntu; Alpine/musl is not in the matrix. +- **No output yet:** `read()` is non-blocking; follow the select loop in the + quickstart instead of assuming the child has already produced output. +- **A manually killed relay left the terminal unusable:** run `stty sane` in + that terminal. Normal exit and handled signals restore the exact saved mode. + ## Read this before you use it **`pcntl_fork()` duplicates the entire process.** Every open PDO connection, @@ -77,74 +126,26 @@ discipline echoes your writes straight back to the master before the child has read anything. If you are measuring round-trip latency, you are measuring the kernel, not the child. Send `stty -echo` or set the termios flags yourself. -## The `ioctl` ABI trap - -This is the finding that made the package worth publishing. +## Reading and writing -`ioctl` is variadic in C: `int ioctl(int, unsigned long, ...)`. Nearly every -PHP + FFI snippet on the web declares it with fixed arity: +A PTY is a byte stream. `read()` is non-blocking: an empty string can mean +there is no data yet. Wait for readiness with `stream_select()` and continue +reading while the child is active. After it exits, drain any remaining output +before closing the session. Waiting for exit before reading can block a child +whose output is waiting to be consumed. -```c -int ioctl(int fd, unsigned long request, void *arg); /* wrong */ -``` +`write()` retries partial writes until its deadline and returns the number of +bytes accepted. Keep `substr($payload, $written)` when the count is short. +For full-duplex relays, interleave reads and writes and bound both queues, as +[the interactive example](examples/interactive.php) does. A writable stream +can still accept only part of a buffer. `write(timeout: 0)` sends no bytes. -On Linux x86-64 that works, because the variadic and non-variadic ABIs coincide -for integers and pointers. **On Darwin arm64 it does not.** Apple diverges from -standard AAPCS64: every variadic argument is passed on the stack, while fixed -arguments go in registers. libffi puts the pointer in a register, the kernel -reads it off the stack, and `TIOCSWINSZ` copies from whatever address happened -to be sitting there. +The terminal echoes input by default. Use `stty -echo` inside the child to +turn echo off; use `stty raw -echo` for byte-oriented protocols. Changing the +local terminal is a separate operation, and its settings must be restored. -The failure mode is the nasty kind — **`ioctl` returns `0`.** No errno, no -exception. Just a silently wrong window size. - -Measured on PHP 8.5.8 / Darwin / arm64, asking for 30×120: - -| declaration | `stty size` in the child | return | -|---|---|---| -| `openpty(..., struct winsize *winp)` | `30 120` ✅ | 0 | -| `int ioctl(int, unsigned long, void *)` | `0 2046` ❌ | **0** | -| `int ioctl(int, unsigned long, ...)` | `30 120` ✅ | 0 | - -The fix is one line of `cdef`. `tests/AbiRegressionTest.php` guards it, and CI -runs on `macos-latest` precisely because Ubuntu alone would give a false green. - -## Partial writes - -`fwrite()` on a pty master routinely writes fewer bytes than you asked for. Drop -the return value and you drop the tail — and when the cut lands mid escape -sequence, the terminal prints the remainder as literal text. A stray `7G` on -screen where a cursor move was meant. - -This is not an edge case, it is the normal regime. Measured on PHP 8.5.8 / -Darwin / arm64, pushing 1 MB through a pty master in 8 KB calls: **1 677 -`fwrite()` calls instead of the 128 a full write would need** — about 625 bytes -accepted per call on average. Code that ignores the return value loses bytes -thirteen times out of fourteen. - -There is a second, nastier layer. PHP buffers stream writes in userspace and -retries the flush in a loop you cannot see or interrupt. On a pty master whose -buffer is full, `fwrite()` then simply never returns, and no amount of -application-level timeout will save you. `Pty::spawn()` sets -`stream_set_write_buffer($stream, 0)` so every `fwrite()` maps to exactly one -`write(2)` and hands `EAGAIN` straight back. - -`Session::write()` loops until the buffer is drained, under a deadline. The -deadline is not paranoia: if the child stops reading — because it is blocked -writing back to a master nobody drains, or because the line discipline is in -canonical mode waiting for a newline that never comes — the buffer stays full -forever and an unbounded loop hangs your process. - -Two related traps, both worth knowing before you write your own relay: - -- **`stty -echo` is not `stty raw`.** The first only silences the echo; the - line discipline stays canonical, holding at most `MAX_CANON` bytes while it - waits for a newline. Push half a megabyte with no `\n` through it and it - jams. -- **Never write a large payload to a child that echoes it back unless you - drain as you go.** The master's output buffer fills, the child blocks - writing, so it stops reading, so your write blocks. Interleave with - `stream_select()` on both directions. +The measured partial-write behaviour and the Darwin ARM64 variadic `ioctl` +ABI regression are explained in [Implementation notes](docs/implementation-notes.md). ## API @@ -196,6 +197,13 @@ FFI, `pcntl_fork()` and command execution are all dangerous by design here. vulnerability privately. Short version: never pass user-controlled input as the executable, and do not work around the CLI-only check. +## Contributing and releases + +See [CONTRIBUTING.md](CONTRIBUTING.md) for local checks and the CI matrix. +Changes not yet included in a tag are listed under **Unreleased** in the +[CHANGELOG](CHANGELOG.md). Check that section when comparing `main` with an +installed Composer version. + ## Credits The FFI/`pcntl` approach was validated against diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md new file mode 100644 index 0000000..648674d --- /dev/null +++ b/docs/implementation-notes.md @@ -0,0 +1,70 @@ +# Implementation notes + +## The `ioctl` ABI trap + +This is the finding that made the package worth publishing. + +`ioctl` is variadic in C: `int ioctl(int, unsigned long, ...)`. Nearly every +PHP + FFI snippet on the web declares it with fixed arity: + +```c +int ioctl(int fd, unsigned long request, void *arg); /* wrong */ +``` + +On Linux x86-64 that works, because the variadic and non-variadic ABIs coincide +for integers and pointers. **On Darwin arm64 it does not.** Apple diverges from +standard AAPCS64: every variadic argument is passed on the stack, while fixed +arguments go in registers. libffi puts the pointer in a register, the kernel +reads it off the stack, and `TIOCSWINSZ` copies from whatever address happened +to be sitting there. + +The failure mode is the nasty kind — **`ioctl` returns `0`.** No errno, no +exception. Just a silently wrong window size. + +Measured on PHP 8.5.8 / Darwin / arm64, asking for 30×120: + +| declaration | `stty size` in the child | return | +|---|---|---| +| `openpty(..., struct winsize *winp)` | `30 120` ✅ | 0 | +| `int ioctl(int, unsigned long, void *)` | `0 2046` ❌ | **0** | +| `int ioctl(int, unsigned long, ...)` | `30 120` ✅ | 0 | + +The fix is one line of `cdef`. `tests/AbiRegressionTest.php` guards it, and CI +runs on `macos-latest` precisely because Ubuntu alone would give a false green. + +## Partial writes + +`fwrite()` on a pty master routinely writes fewer bytes than you asked for. Drop +the return value and you drop the tail — and when the cut lands mid escape +sequence, the terminal prints the remainder as literal text. A stray `7G` on +screen where a cursor move was meant. + +This is not an edge case, it is the normal regime. Measured on PHP 8.5.8 / +Darwin / arm64, pushing 1 MB through a pty master in 8 KB calls: **1 677 +`fwrite()` calls instead of the 128 a full write would need** — about 625 bytes +accepted per call on average. Code that ignores the return value loses bytes +thirteen times out of fourteen. + +There is a second, nastier layer. PHP buffers stream writes in userspace and +retries the flush in a loop you cannot see or interrupt. On a pty master whose +buffer is full, `fwrite()` then simply never returns, and no amount of +application-level timeout will save you. `Pty::spawn()` sets +`stream_set_write_buffer($stream, 0)` so every `fwrite()` maps to exactly one +`write(2)` and hands `EAGAIN` straight back. + +`Session::write()` loops until the buffer is drained, under a deadline. The +deadline is not paranoia: if the child stops reading — because it is blocked +writing back to a master nobody drains, or because the line discipline is in +canonical mode waiting for a newline that never comes — the buffer stays full +forever and an unbounded loop hangs your process. + +Two related traps, both worth knowing before you write your own relay: + +- **`stty -echo` is not `stty raw`.** The first only silences the echo; the + line discipline stays canonical, holding at most `MAX_CANON` bytes while it + waits for a newline. Push half a megabyte with no `\n` through it and it + jams. +- **Never write a large payload to a child that echoes it back unless you + drain as you go.** The master's output buffer fills, the child blocks + writing, so it stops reading, so your write blocks. Interleave with + `stream_select()` on both directions. diff --git a/examples/bootstrap.php b/examples/bootstrap.php new file mode 100644 index 0000000..e0a1b1c --- /dev/null +++ b/examples/bootstrap.php @@ -0,0 +1,59 @@ +close(); + if ($session->wait(0.2) === -1 && $session->isRunning()) { + $session->terminate(); + if ($session->wait(0.2) === -1 && $session->isRunning()) { + $session->kill(); + $session->wait(2.0); + } + } +} + +/** @param list $arguments */ +function stty(array $arguments): string +{ + $process = proc_open(['stty', ...$arguments], [0 => STDIN, 1 => ['pipe', 'w'], 2 => STDERR], $pipes); + if (! is_resource($process)) { + throw new RuntimeException('Cannot start stty. Install it to run the interactive example.'); + } + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $status = proc_close($process); + if ($status !== 0 || $output === false) { + throw new RuntimeException('stty failed. Run this example directly in a terminal.'); + } + + return trim($output); +} + +/** @return array{int, int} */ +function terminalSize(): array +{ + if (preg_match('/^(\d+)\s+(\d+)$/', stty(['size']), $size) !== 1) { + throw new RuntimeException('Cannot read the local terminal size.'); + } + + return [max(1, (int) $size[1]), max(1, (int) $size[2])]; +} diff --git a/examples/interactive.php b/examples/interactive.php new file mode 100644 index 0000000..26e7054 --- /dev/null +++ b/examples/interactive.php @@ -0,0 +1,182 @@ +resize || $this->stop !== null; + } +}; +$previousHandlers = []; +$previousAsync = pcntl_async_signals(true); +$stdinBlocking = stream_get_meta_data(STDIN)['blocked']; +$stdoutBlocking = stream_get_meta_data(STDOUT)['blocked']; + +try { + foreach ([SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGWINCH] as $signal) { + $previousHandlers[$signal] = pcntl_signal_get_handler($signal); + pcntl_signal($signal, function (int $received) use ($signals): void { + if ($received === SIGWINCH) { + $signals->resize = true; + } else { + $signals->stop = $received; + } + }); + } + + $savedMode = stty(['-g']); + [$rows, $cols] = terminalSize(); + $session = Pty::spawn($command, rows: $rows, cols: $cols); + stty(['raw', '-echo']); + stream_set_blocking(STDIN, false); + stream_set_blocking(STDOUT, false); + stream_set_write_buffer(STDOUT, 0); + + $master = $session->stream(); + $input = $output = ''; + $outputClosed = false; + $limit = 65536; + + while ($signals->stop === null) { + if ($signals->resize) { + $signals->resize = false; + [$rows, $cols] = terminalSize(); + $session->resize($rows, $cols); + } + $running = $session->isRunning(); + if (! $running || $outputClosed) { + $input = ''; + } + if (! $running && $outputClosed && $output === '') { + $exitCode = $session->wait(0.0); + break; + } + + // Apply backpressure in both directions, keeping each queue <= 64 KB. + $read = $write = $except = []; + if ($running && ! $outputClosed && strlen($input) < $limit) { + $read[] = STDIN; + } + if (! $outputClosed && strlen($output) < $limit) { + $read[] = $master; + } + if ($input !== '') { + $write[] = $master; + } + if ($output !== '') { + $write[] = STDOUT; + } + if ($read === [] && $write === []) { + // Closing terminal descriptors does not imply process exit. + usleep(10_000); + + continue; + } + $selected = @stream_select($read, $write, $except, 0, 100_000); + if ($selected === false) { + // Signals interrupt select. Process their flags on the next turn. + if ($signals->interrupted()) { + continue; + } + throw new RuntimeException('Cannot select the terminal streams.'); + } + + foreach ($read as $stream) { + if ($stream === STDIN) { + $chunk = fread(STDIN, min(8192, $limit - strlen($input))); + if ($chunk === false || ($chunk === '' && feof(STDIN))) { + throw new RuntimeException('The local terminal input closed.'); + } + $input .= $chunk; + } else { + $chunk = $session->read(min(8192, $limit - strlen($output))); + $output .= $chunk; + if ($chunk === '' && (feof($master) || ! $session->isRunning())) { + $outputClosed = true; + } + } + } + + foreach ($write as $stream) { + if ($stream === $master && ($outputClosed || ! $session->isRunning())) { + $input = ''; + + continue; + } + // A single non-blocking write, with its suffix retained. Calling + // Session::write(timeout: 0) here would never write any bytes. + $buffer = $stream === STDOUT ? $output : $input; + $written = @fwrite($stream, substr($buffer, 0, 8192)); + if ($written === false) { + throw new RuntimeException('A terminal write failed.'); + } + if ($stream === STDOUT) { + $output = substr($output, $written); + } else { + $input = substr($input, $written); + } + } + } + $exitCode = $signals->stop === null ? ($exitCode < 0 ? 1 : $exitCode) : 128 + $signals->stop; +} catch (Throwable $error) { + $message = $error->getMessage(); + $exitCode = 1; +} finally { + stream_set_blocking(STDIN, $stdinBlocking); + stream_set_blocking(STDOUT, $stdoutBlocking); + try { + if ($savedMode !== null) { + stty([$savedMode]); + } + } catch (Throwable $error) { + $message = $error->getMessage(); + $exitCode = 1; + } finally { + if ($session !== null) { + cleanup($session); + } + foreach ($previousHandlers as $signal => $handler) { + pcntl_signal($signal, $handler); + } + pcntl_async_signals($previousAsync); + } +} +if (isset($message)) { + fwrite(STDERR, $message.PHP_EOL); +} +exit($exitCode); diff --git a/examples/multiple-sessions.php b/examples/multiple-sessions.php new file mode 100644 index 0000000..46ec895 --- /dev/null +++ b/examples/multiple-sessions.php @@ -0,0 +1,87 @@ + [PHP_BINARY, '-r', 'for ($i = 1; $i <= 3; $i++) { echo "tick $i\n"; usleep(50000); }'], + 'slow' => [PHP_BINARY, '-r', 'for ($i = 1; $i <= 3; $i++) { echo "tick $i\n"; usleep(150000); }'], +]; +$sessions = $buffers = $eof = []; +$exitCode = 0; + +// These fixed commands emit short lines. Flush any final line at process exit. +$emit = function (string $name, string $chunk, bool $final = false) use (&$buffers): void { + $buffers[$name] .= $chunk; + while (($newline = strpos($buffers[$name], "\n")) !== false) { + echo '['.$name.'] '.rtrim(substr($buffers[$name], 0, $newline), "\r").PHP_EOL; + $buffers[$name] = substr($buffers[$name], $newline + 1); + } + if ($final && $buffers[$name] !== '') { + echo '['.$name.'] '.$buffers[$name].PHP_EOL; + $buffers[$name] = ''; + } +}; + +try { + foreach ($commands as $name => $command) { + $sessions[$name] = Pty::spawn($command); + $buffers[$name] = ''; + $eof[$name] = false; + } + $deadline = hrtime(true) + 10_000_000_000; + while ($sessions !== []) { + if (hrtime(true) >= $deadline) { + throw new RuntimeException('The examples did not finish within ten seconds.'); + } + $read = []; + foreach ($sessions as $name => $session) { + if (! $eof[$name]) { + $read[$name] = $session->stream(); + } + } + $write = $except = []; + if ($read !== []) { + if (stream_select($read, $write, $except, 0, 100_000) === false) { + throw new RuntimeException('Cannot select the terminal streams.'); + } + foreach ($read as $name => $stream) { + $emit($name, $sessions[$name]->read()); + $eof[$name] = feof($stream); + } + } else { + // EOF streams are always readable; retire them while awaiting exit. + usleep(10_000); + } + + foreach ($sessions as $name => $session) { + if (! $session->isRunning()) { + while (($tail = $session->read()) !== '') { + $emit($name, $tail); + } + $emit($name, '', final: true); + $status = $session->wait(0.0); + echo "[{$name}] exited {$status}".PHP_EOL; + if ($status !== 0) { + $exitCode = 1; + } + $session->close(); + unset($sessions[$name]); + } + } + } +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage().PHP_EOL); + $exitCode = 1; +} finally { + foreach ($sessions as $session) { + cleanup($session); + } +} +exit($exitCode); diff --git a/examples/quickstart.php b/examples/quickstart.php new file mode 100644 index 0000000..e3ab54c --- /dev/null +++ b/examples/quickstart.php @@ -0,0 +1,43 @@ +stream()]; + $write = $except = []; + if (stream_select($read, $write, $except, 0, 100_000) > 0) { + echo $session->read(); + } + // Reap only after draining; a child can wait for its output to be read. + $running = $session->isRunning(); + } while ($running && hrtime(true) < $deadline); + + // Exiting and having no more output are separate events. + while (($tail = $session->read()) !== '') { + echo $tail; + } + $exitCode = $session->wait(0.0); + if ($exitCode < 0) { + throw new RuntimeException('The example did not finish within ten seconds.'); + } +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage().PHP_EOL); + $exitCode = 1; +} finally { + if ($session !== null) { + cleanup($session); + } +} +exit($exitCode); diff --git a/phpstan.neon b/phpstan.neon index 815fdd2..e7e01f7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ parameters: level: 6 paths: - src + - examples stubFiles: - phpstan/ffi.stub scanFiles: diff --git a/src/Session.php b/src/Session.php index 2bdbc6f..227bdbf 100644 --- a/src/Session.php +++ b/src/Session.php @@ -182,7 +182,11 @@ public function kill(): bool return $this->signal(SIGKILL); } - /** Non-blocking liveness check. Reaps the child if it has exited. */ + /** + * Non-blocking liveness check. Reaps the child if it has exited. + * + * @phpstan-impure + */ public function isRunning(): bool { if ($this->reaped) { @@ -215,6 +219,8 @@ public function isRunning(): bool * no escape is a defect, however unlikely the case. Pass `null` for the * old blocking behaviour, explicitly. * + * @phpstan-impure + * * @return int Exit code, `128 + signal` when killed, or -1 on timeout/unavailable status. */ public function wait(?float $timeout = 10.0): int diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php new file mode 100644 index 0000000..a34a3bb --- /dev/null +++ b/tests/ExamplesTest.php @@ -0,0 +1,152 @@ + $command */ +function interactiveExample(array $command, int $rows = 32, int $cols = 104): Session +{ + return Pty::spawn([ + '/bin/sh', __DIR__.'/Fixtures/run-interactive.sh', + PHP_BINARY, __DIR__.'/../examples/interactive.php', '--', ...$command, + ], rows: $rows, cols: $cols); +} + +it('runs the quickstart and reports the requested geometry', function (): void { + $session = Pty::spawn([PHP_BINARY, __DIR__.'/../examples/quickstart.php']); + try { + expect(drain($session, 10.0, '/30\s+120/'))->toMatch('/30\s+120/'); + expect($session->wait(2.0))->toBe(0); + } finally { + stopSession($session); + } +}); + +it('runs multiple sessions to completion with all output labelled once', function (): void { + $session = Pty::spawn([PHP_BINARY, __DIR__.'/../examples/multiple-sessions.php']); + try { + $output = drain($session, 10.0, '/\[slow\] exited 0/'); + expect($session->wait(2.0))->toBe(0); + foreach (['fast', 'slow'] as $name) { + foreach (range(1, 3) as $tick) { + expect(substr_count($output, "[{$name}] tick {$tick}"))->toBe(1); + } + expect(substr_count($output, "[{$name}] exited 0"))->toBe(1); + } + } finally { + stopSession($session); + } +}); + +it('relays input and initial geometry, preserves exit codes and restores the terminal', function (): void { + $session = interactiveExample([ + '/bin/sh', '-c', 'stty -echo; stty size; echo READY; IFS= read -r line; printf "REPLY:%s\n" "$line"; exit 42', + ]); + try { + $start = drain($session, 10.0, '/READY/'); + expect($start)->toMatch('/32\s+104/'); + $session->write("spaces and ; literal\n"); + $output = drain($session, 10.0, '/TERMINAL-RESTORED/'); + expect($output)->toContain('REPLY:spaces and ; literal', 'TERMINAL-RESTORED'); + expect($session->wait(2.0))->toBe(42); + } finally { + stopSession($session); + } +}); + +it('forwards local terminal resizes to the child', function (): void { + $session = interactiveExample([PHP_BINARY, __DIR__.'/Fixtures/watch-resize.php']); + try { + expect(drain($session, 10.0, '/READY/'))->toContain('READY'); + $session->resize(45, 132); + $output = drain($session, 10.0, '/TERMINAL-RESTORED/'); + expect($output)->toContain('GOT-WINCH', 'TERMINAL-RESTORED'); + expect($session->wait(2.0))->toBe(0); + } finally { + stopSession($session); + } +}); + +it('forwards Ctrl-C as terminal input to the foreground child', function (): void { + $session = interactiveExample([PHP_BINARY, __DIR__.'/Fixtures/relay-child.php', 'signal']); + try { + expect(drain($session, 10.0, '/READY/'))->toContain('READY'); + $session->write("\x03"); + $output = drain($session, 10.0, '/TERMINAL-RESTORED/'); + expect($output)->toContain('INTERRUPTED', 'TERMINAL-RESTORED'); + expect($session->wait(2.0))->toBe(42); + } finally { + stopSession($session); + } +}); + +it('drains a large final output through a slow outer terminal without corruption', function (): void { + $session = interactiveExample([PHP_BINARY, __DIR__.'/Fixtures/relay-child.php']); + try { + expect(drain($session, 10.0, '/READY/'))->toContain('READY'); + $session->write('x'); + // Let both the relay output queue and the outer PTY buffer fill. + usleep(150_000); + $output = drain($session, 10.0, '/TERMINAL-RESTORED/'); + expect($session->wait(2.0))->toBe(0); + expect(preg_match('/BEGIN\n(.*)END\n/s', $output, $matches))->toBe(1); + $expected = str_repeat("a\033[7Gb\n", 80_000); + expect(strlen($matches[1]))->toBe(strlen($expected)); + expect(hash('sha256', $matches[1]))->toBe(hash('sha256', $expected)); + expect($output)->toContain('TERMINAL-RESTORED'); + } finally { + stopSession($session); + } +}); + +it('restores the terminal when the relay receives SIGTERM', function (): void { + $session = interactiveExample([PHP_BINARY, __DIR__.'/Fixtures/relay-child.php', 'signal']); + try { + $start = drain($session, 10.0, '/READY/'); + expect(preg_match('/RELAY:(\d+)/', $start, $matches))->toBe(1); + expect(posix_kill((int) $matches[1], SIGTERM))->toBeTrue(); + expect(drain($session, 10.0, '/TERMINAL-RESTORED/'))->toContain('TERMINAL-RESTORED'); + expect($session->wait(2.0))->toBe(128 + SIGTERM); + } finally { + stopSession($session); + } +}); + +it('reports startup errors without leaving the terminal in raw mode', function (): void { + $session = interactiveExample(['/php-pty-no-such-executable']); + try { + $output = drain($session, 10.0, '/TERMINAL-RESTORED/'); + expect($output)->toContain('Executable', 'TERMINAL-RESTORED'); + expect($session->wait(2.0))->toBe(1); + } finally { + stopSession($session); + } +}); + +it('explains why interactive mode cannot be run with redirected streams', function (): void { + $process = proc_open([PHP_BINARY, __DIR__.'/../examples/interactive.php'], [ + 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], + ], $pipes); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(1); + expect($error)->toContain('directly in a terminal'); +}); + +it('waits for the child after it closes its terminal descriptors', function (): void { + $session = interactiveExample([ + PHP_BINARY, '-r', 'echo "READY\n"; fclose(STDIN); fclose(STDOUT); fclose(STDERR); usleep(600000); exit(42);', + ]); + try { + expect(drain($session, 10.0, '/TERMINAL-RESTORED/'))->toContain('READY', 'TERMINAL-RESTORED'); + expect($session->wait(2.0))->toBe(42); + } finally { + stopSession($session); + } +}); diff --git a/tests/Fixtures/relay-child.php b/tests/Fixtures/relay-child.php new file mode 100644 index 0000000..9b7b098 --- /dev/null +++ b/tests/Fixtures/relay-child.php @@ -0,0 +1,40 @@ + STDIN, 1 => STDOUT, 2 => STDERR], $pipes); +if (! is_resource($process) || proc_close($process) !== 0) { + exit(1); +} + +pcntl_async_signals(true); +pcntl_signal(SIGINT, function (): void { + fwrite(STDOUT, "INTERRUPTED\n"); + exit(42); +}); + +if (($argv[1] ?? '') === 'signal') { + // This mode retains ISIG so the literal Ctrl-C byte reaches SIGINT. + $process = proc_open(['stty', 'isig'], [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes); + if (! is_resource($process) || proc_close($process) !== 0) { + exit(1); + } + echo 'RELAY:'.posix_getppid()."\nREADY\n"; + while (true) { + usleep(10_000); + } +} + +$payload = str_repeat("a\033[7Gb\n", 80_000); +fwrite(STDOUT, "READY\n"); +fread(STDIN, 1); +fwrite(STDOUT, "BEGIN\n"); +$offset = 0; +while ($offset < strlen($payload)) { + $written = fwrite(STDOUT, substr($payload, $offset, 8192)); + if ($written === false) { + exit(1); + } + $offset += $written; +} +fwrite(STDOUT, "END\n"); diff --git a/tests/Fixtures/run-interactive.sh b/tests/Fixtures/run-interactive.sh new file mode 100644 index 0000000..52590a8 --- /dev/null +++ b/tests/Fixtures/run-interactive.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# macOS adds PENDIN when leaving raw mode. It is transient kernel state, +# not a persistent setting; mask just this bit in the comparison. +terminal_settings() { + mode=$(stty -g) || return 1 + "$1" -r ' + $mode = $argv[1]; + if (PHP_OS_FAMILY === "Darwin") { + $mode = preg_replace_callback("/(?<=:lflag=)[0-9a-f]+/", static fn ($m) => dechex(hexdec($m[0]) & ~0x20000000), $mode); + } + echo $mode; + ' "$mode" +} +before=$(terminal_settings "$1") || exit 1 +"$@" +status=$? +after=$(terminal_settings "$1") || exit 1 +if [ "$before" = "$after" ]; then + printf '\nTERMINAL-RESTORED\n' +else + printf '\nTERMINAL-NOT-RESTORED\n' + printf 'before=%s\nafter=%s\n' "$before" "$after" + exit 1 +fi +exit "$status"