← All 41 libraries

CandyPty

🖥️ CandyPty

Pseudo-terminal primitive — open / spawn / read / write / resize

port of charmbracelet/x/xpty linux + macos ext-ffi posix_openpt tiocswinsz sigwinch

CandyPty code coverage

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.

Install

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.

Open + spawn + drain

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();

Resize forwarding

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
);

What's in the box

Pty::open(cols = 80, rows = 24)Allocates a master/slave pair by delegating to 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.
spawn(cmd, env, cols, rows, controllingTerminal?)Wires the slave path to 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 / write / setBlockingread($len, $timeout) returns null on timeout, '' on EOF, bytes otherwise. Non-blocking + stream_select with EINTR retry. write returns bytes actually written.
resize() / size()TIOCSWINSZ on the master fd; size() reads back via TIOCGWINSZ. Platform-aware constants for Linux + macOS.
PtySystemFactory::new()Platform-resolving entry point to the 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.
SignalForwarderWires host SIGWINCH → Pty::resize() via a caller-supplied size provider, plus optional SIGCHLD reaper. Defaults pcntl_async_signals(true); falls back cleanly when pcntl is missing.
waitpid fast-pathChild::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.
ControllingTerminal::claim()Static method: 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.
PosixPump + PumpOptionsByte pump with 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.
SUGARCRAFT_LIBC overrideDefaults to libc.so.6 on Linux, /usr/lib/libSystem.B.dylib on macOS. Override via env var for musl, Alpine, or custom sysroots.
SUGARCRAFT_PTY_BACKENDSelects the PTY backend. Recognised values: 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.

Use it for

Controlling terminal (Ctrl+C, job control)

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.

Known limitations

Source & demos

Try the quickstart →

API

ClassMethodDescription
Ptyopen(cols = 80, rows = 24)Allocate master/slave PTY pair (FD_CLOEXEC master, winsize applied at open)
Ptyspawn(cmd, env, cols, rows, controllingTerminal = false)Spawn child process with PTY; winsize applied before the spawn and re-asserted after it
Ptyread(len, timeout)Read bytes from PTY
Ptywrite(data)Write bytes to PTY
Ptyresize(cols, rows)Resize terminal window
Ptysize()Get current terminal size
Ptyclose()Idempotent; always close(2)s the master fd and throws PtyException if that close (or the stream wrapper's fclose) fails
PtySystemFactorynew()Build the platform PtySystem, honouring SUGARCRAFT_PTY_BACKEND (default() is a deprecated alias)
PosixSlavePtyspawn(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
Libckill(pid, signal)posix_kill() when ext-posix is loaded, libc kill(2) over FFI otherwise
TermiosFactoryfallbackCount()How many times the FFI termios backend fell back to stty (each fallback is also logged)
SignalForwarderattachSigwinch(pty, provider)Forward SIGWINCH to PTY resize
SignalForwarderattachSigwinchToFd(fd, provider, onResize?)Forward SIGWINCH to raw /dev/tty fd via TIOCSWINSZ
PosixPumprun(master, stdin, stdout, child?, opts?)Run byte pump with optional PumpOptions callbacks
PumpOptionswithOnIdle(fn)Register idle-tick callback (fires every stream_select timeout)
PumpOptionswithOnSigwinch(fn(cols, rows))Register SIGWINCH callback (fires on terminal resize)
PumpOptionswithOnChildExit(fn(code))Register child-exit callback
PumpOptionswithRecorder(recorder)Attach session recorder (tee stdin/master bytes)
PumpOptionssshDefault()SSH-session-tuned preset — matches values hardcoded in InProcessTransport
ChildpidChild process ID
Childexited(), wait()Check/await child exit
ControllingTerminalclaim(fd)Claim a fd as controlling terminal via setsid() + ioctl(TIOCSCTTY)