Pseudo-terminal primitive — open / spawn / read / write / resize
Open a master/slave PTY pair, spawn a child process with its stdin/stdout/stderr wired to the slave, and pump bytes / forward resizes between the host terminal and the child. Wraps the libc PTY syscalls (posix_openpt, grantpt, unlockpt, ptsname_r, ioctl(TIOCSWINSZ)) via ext-ffi — no shelling out to /usr/bin/script, no C extension to compile.
composer require sugarcraft/candy-pty
Requires PHP 8.1+ with ext-ffi. ext-pcntl is optional — the lib polls waitpid() when pcntl is absent and SignalForwarder degrades to a no-op.
use SugarCraft\Pty\Pty;
$pty = Pty::open();
$child = $pty->spawn(
['/bin/bash', '-c', 'echo $TERM; date'],
['TERM' => 'xterm-256color'],
100, 30, // cols × rows
);
$pty->setBlocking(false);
$out = '';
while (!$child->exited()) {
$chunk = $pty->read(4096, 0.05); // 50ms timeout
if ($chunk === null || $chunk === '') continue;
$out .= $chunk;
}
$exit = $child->wait();
$pty->close();
use SugarCraft\Pty\SignalForwarder;
use SugarCraft\Core\Util\Tty;
SignalForwarder::attachSigwinch(
$pty,
fn () => Tty::size(), // [cols => N, rows => N]
);
// Now every host SIGWINCH triggers $pty->resize($cols, $rows).
// For raw /dev/tty fds (no MasterPty object):
$ttyFd = \SugarCraft\Pty\Libc::lib()->open('/dev/tty', 0x0002); // O_RDWR
SignalForwarder::attachSigwinchToFd(
$ttyFd,
fn () => Tty::size(),
fn (int $cols, int $rows) => null, // optional post-resize callback
);
openpty(3) first (single call), falls back to posix_openpt + grantpt + unlockpt + ptsname_r on -1. On Linux: the quartet directly (openpty lives in libutil.so.1 on most distros). Failures close the master fd before throwing — callers never get a half-open Pty.proc_open's [0,1,2] descriptor slots and resizes via TIOCSWINSZ before the child starts. Pass controllingTerminal: true to route through bin/pty-shim.php for Ctrl+C → SIGINT delivery (requires ext-pcntl). Returns a Child with pid + idempotent wait() + non-blocking exited().read($len, $timeout) returns null on timeout, '' on EOF, bytes otherwise. Non-blocking + stream_select with EINTR retry. write returns bytes actually written.size() reads back via TIOCGWINSZ. Platform-aware constants for Linux + macOS.Pty::resize() via a caller-supplied size provider, plus optional SIGCHLD reaper. Defaults pcntl_async_signals(true); falls back cleanly when pcntl is missing.Child::exited() and Child::wait() use a waitpid(pid, &status, WNOHANG) FFI call as the first exit probe — sub-ms detection instead of the 10 ms proc_get_status poll. If FFI is unavailable, falls back to proc_get_status transparently. Signal-terminated exit codes follow the Unix convention: 128 + signal_number.setsid() + ioctl(fd, TIOCSCTTY, 0) to claim a fd as the session's controlling terminal. Used by the controllingTerminal: true shim path; callable directly from FFI-heavy code that needs the same effect without the PHP shim overhead. Throws PtyException on failure.onIdle (idle tick), onSigwinch (dimension change), onChildExit (child exit), and recorder (session tap) callbacks. Wire via PumpOptions::withOnIdle() / withOnSigwinch() / withOnChildExit() / withRecorder().libc.so.6 on Linux, /usr/lib/libSystem.B.dylib on macOS. Override via env var for musl, Alpine, or custom sysroots.posix-ffi (default on POSIX), auto (same as unset), sidecar or pecl (throw UnsupportedPlatformException — deferred to phase 12). Unrecognised values throw InvalidArgumentException. See the full backend selection table in the README.Pass controllingTerminal: true to spawn() when you need Ctrl+C typed at the master to deliver SIGINT to the child — required for interactive shells (bash -i), editors (vim, less), and anything else that uses tty-driven job control.
$child = $pty->spawn(
['/bin/bash', '-i'],
env: [...],
controllingTerminal: true, // claim slave as the child's ctty
);
$pty->write("\x03"); // Ctrl+C → SIGINT to the child
Routes the spawn through bin/pty-shim.php, which does setsid() + ioctl(0, TIOCSCTTY, 0) + pcntl_exec() between proc_open and the actual cmd. Requires ext-pcntl. Costs ~5-50 ms of shim startup per spawn — opt-in because non-interactive spawns don't benefit.
| Class | Method | Description |
|---|---|---|
| Pty | open() | Allocate master/slave PTY pair |
| Pty | spawn(cmd, env, cols, rows) | Spawn child process with PTY |
| Pty | read(len, timeout) | Read bytes from PTY |
| Pty | write(data) | Write bytes to PTY |
| Pty | resize(cols, rows) | Resize terminal window |
| Pty | size() | Get current terminal size |
| Pty | close() | Close the PTY |
| SignalForwarder | attachSigwinch(pty, provider) | Forward SIGWINCH to PTY resize |
| SignalForwarder | attachSigwinchToFd(fd, provider, onResize?) | Forward SIGWINCH to raw /dev/tty fd via TIOCSWINSZ |
| PosixPump | run(master, stdin, stdout, child?, opts?) | Run byte pump with optional PumpOptions callbacks |
| PumpOptions | withOnIdle(fn) | Register idle-tick callback (fires every stream_select timeout) |
| PumpOptions | withOnSigwinch(fn(cols, rows)) | Register SIGWINCH callback (fires on terminal resize) |
| PumpOptions | withOnChildExit(fn(code)) | Register child-exit callback |
| PumpOptions | withRecorder(recorder) | Attach session recorder (tee stdin/master bytes) |
| PumpOptions | sshDefault() | SSH-session-tuned preset — matches values hardcoded in InProcessTransport |
| Child | pid | Child process ID |
| Child | exited(), wait() | Check/await child exit |
| ControllingTerminal | claim(fd) | Claim a fd as controlling terminal via setsid() + ioctl(TIOCSCTTY) |