← All libraries

CandyPty

🖥️ CandyPty

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

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

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.1+ with ext-ffi. ext-pcntl is optional — the lib polls waitpid() when pcntl is absent and SignalForwarder degrades to a no-op.

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;

SignalForwarder::attachSigwinch(
    $pty,
    fn () => Tty::size(),                // [cols => N, rows => N]
);
// Now every host SIGWINCH triggers $pty->resize($cols, $rows).

What's in the box

Pty::open()Allocates a master/slave pair via posix_openpt(O_RDWR | O_NOCTTY) + grantpt + unlockpt + ptsname_r. 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 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 / 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.
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.
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().
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: [...],
    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()Allocate master/slave PTY pair
Ptyspawn(cmd, env, cols, rows)Spawn child process with PTY
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()Close the PTY
SignalForwarderattachSigwinch(pty, provider)Forward SIGWINCH to PTY resize
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