Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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).
156 changes: 82 additions & 74 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions docs/implementation-notes.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading