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.3+ with ext-ffi. ext-pcntl is optional — the lib polls waitpid() when pcntl is absent and SignalForwarder degrades to a no-op. ext-posix is optional too — kill() signals through Libc::kill(), which uses posix_kill() when it is loaded and libc kill(2) over FFI otherwise.
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;
$tty = new Tty(STDIN); // the host terminal
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
);
PosixPtySystem::open(), so the facade shares the canonical path exactly. On macOS: 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). The master fd is FD_CLOEXEC (children never inherit it, so closing it delivers the hangup) and the requested winsize is applied at open. Failures close the master fd before throwing — callers never get a half-open Pty.proc_open's [0,1,2] descriptor slots. TIOCSWINSZ is applied before the child starts (throwing, with no child, on failure) and re-asserted best-effort after it, because macOS zeroes the winsize when the child's slave descriptors open. 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.PtySystem contract (open(cols, rows) → pair with master() / slave()). $pair->slave()->spawn() applies an explicit $cols/$rows on every spawn (creack/pty StartWithSize()); omit both to keep the size the pair was opened or last resized with. The parent's slave handle is opened O_CLOEXEC, so the child holds the slave only at fds 0-2. PtySystemFactory::default() remains as a deprecated alias.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(). Back-pressure: when the master accepts only part of a stdin chunk, the pump parks the remainder, stops reading stdin and waits for the master to drain it — input is never reordered or grown without bound, and VEOF is sent only behind the last data byte.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: ['TERM' => 'xterm-256color'],
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(cols = 80, rows = 24) | Allocate master/slave PTY pair (FD_CLOEXEC master, winsize applied at open) |
| Pty | spawn(cmd, env, cols, rows, controllingTerminal = false) | Spawn child process with PTY; winsize applied before the spawn and re-asserted after it |
| 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() | Idempotent; always close(2)s the master fd and throws PtyException if that close (or the stream wrapper's fclose) fails |
| PtySystemFactory | new() | Build the platform PtySystem, honouring SUGARCRAFT_PTY_BACKEND (default() is a deprecated alias) |
| PosixSlavePty | spawn(cmd, env, cols = null, rows = null, controllingTerminal = false) | Spawn on a pair from PtySystem::open(); explicit geometry applied on every spawn, null keeps the pair's current size |
| Libc | kill(pid, signal) | posix_kill() when ext-posix is loaded, libc kill(2) over FFI otherwise |
| TermiosFactory | fallbackCount() | How many times the FFI termios backend fell back to stty (each fallback is also logged) |
| 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) |