sugarcraft/candy-pty

PHP port of charmbracelet/x/xpty — pseudo-terminal abstraction (open / spawn / resize) for SugarCraft. Linux + macOS via FFI to libc.

Maintainers

Package info

github.com/sugarcraft/candy-pty

Documentation

pkg:composer/sugarcraft/candy-pty

Statistics

Installs: 263

Dependents: 6

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-05-18 02:36 UTC

This package is auto-updated.

Last update: 2026-05-18 02:38:52 UTC


README

Tests Coverage License

PHP port of charmbracelet/x/xpty — the pseudo-terminal primitive Charm uses to drive child processes inside their TUIs. Open a master/slave PTY pair, spawn a child with its stdio wired to the slave, pump bytes between the host and the child, and forward host resizes into the child via TIOCSWINSZ.

Status: Linux + macOS. Windows ConPTY is a separate concern tracked in plans/x-windows.md.

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.

Quickstart

use SugarCraft\Pty\Pty;

$pty   = Pty::open();
$child = $pty->spawn(
    ['/bin/bash', '-c', 'echo $TERM; uname -s; date'],
    ['TERM' => 'xterm-256color'],
    100, 30,                              // cols × rows
);

$pty->setBlocking(false);
$out = '';
while (!$child->exited()) {
    $chunk = $pty->read(4096, 0.05);     // 50 ms timeout
    if ($chunk === null || $chunk === '') continue;
    $out .= $chunk;
}
$exit = $child->wait();
$pty->close();

echo $out;

DI-friendly (preferred for libraries)

For consumers that want to stay decoupled from the POSIX backend (useful in tests + when a Windows ConPTY sidecar lands in v2), resolve the PtySystem through the factory:

use SugarCraft\Pty\PtySystemFactory;

$system = PtySystemFactory::default();   // throws UnsupportedPlatformException on Windows
$pair   = $system->open(100, 30);
$master = $pair->master();
$child  = $pair->slave()->spawn(['/bin/bash', '-l'], null, 100, 30, controllingTerminal: true);

Tests can swap in a stub PtySystem without touching libc.

API at a glance

Call What it does
Pty::open(): Pty posix_openpt + grantpt + unlockpt + ptsname_r. Returns a Pty exposing master (readonly fd + slavePath).
$pty->spawn(array $cmd, ?array $env, int $cols=80, int $rows=24, bool $controllingTerminal=false): Child proc_open with slave-path descriptors + initial TIOCSWINSZ. Pass controllingTerminal: true to route through bin/pty-shim.php so the child claims the slave PTY as its ctty (Ctrl+C → SIGINT, job control); requires ext-pcntl.
$pty->read(int $len=8192, ?float $timeout=null): ?string null on timeout, '' on EOF, bytes otherwise. EINTR-safe.
$pty->write(string $bytes): int Returns bytes written.
$pty->setBlocking(bool $blocking): void Toggles non-blocking mode on the master fd.
$pty->resize(int $cols, int $rows): void TIOCSWINSZ on the master fd.
$pty->size(): array{cols,rows,xpix,ypix} TIOCGWINSZ readback.
$pty->stream(): resource Cached php://fd/ wrapper around the master fd for direct PHP-stream use.
$pty->close(): void Idempotent. Routes through fclose if stream() was materialised, else close(2) via FFI.
$child->pid: int OS process id.
$child->wait(): int Blocks via 10ms proc_get_status poll, returns exit code. Idempotent.
$child->exited(): bool Non-blocking probe.

Non-PTY processes

For child processes that don't need a PTY (e.g. a sub-step in a spinner overlay), PosixProcess shares the same lifecycle (pid/exited/wait/exitCode/kill) but binds stdin to /dev/null and lets you capture stdout/stderr into in-memory buffers:

use SugarCraft\Pty\Posix\PosixProcess;

$proc = PosixProcess::spawn(
    ['/bin/sh', '-c', 'echo out; echo err >&2'],
    env: null,
    captureStdout: true,
    captureStderr: true,
);
$exit = $proc->wait();
echo $proc->stdoutBytes();   // "out\n"
echo $proc->stderrBytes();   // "err\n"

When a capture flag is false, the corresponding stream is inherited from the parent's STDOUT / STDERR and the matching *Bytes() accessor returns ''.

Resize forwarding

use SugarCraft\Pty\SignalForwarder;
use SugarCraft\Core\Util\Tty;

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

The forwarder defaults to pcntl_async_signals(true) so handlers fire between PHP opcodes; pass async: false if your event loop already polls pcntl_signal_dispatch() itself.

Recording sessions (Recorder tap)

PumpOptions accepts an optional SugarCraft\Core\Recorder — when set, PosixPump tees stdin chunks (recordInputBytes) and master-read chunks (recordOutput) into the recorder on the same loop iteration as the read. Null = zero overhead.

use SugarCraft\Pty\Posix\PosixPump;
use SugarCraft\Pty\PumpOptions;
use SugarCraft\Vcr\Recorder;

$recorder = Recorder::open('/tmp/session.cas');
$opts     = (new PumpOptions())->withRecorder($recorder);
$exit     = (new PosixPump())->run($master, STDIN, STDOUT, $child, $opts);
$recorder->recordQuit();
$recorder->close();
// /tmp/session.cas can now be walked via SugarCraft\Vcr\Format\JsonlFormat
// or driven through SugarCraft\Vcr\Player::play() against a candy-core Program.

Resize events still need a separate SignalForwarder callback that chains into $recorder->recordResize($cols, $rows) — the pump itself stays clear of SIGWINCH detection so it can be reused for non-interactive recordings.

Multi-pump

MultiPump demuxes multiple PTY sessions from a single stream_select loop — a split-pane viewer, parallel test harness, or tmux-style supervisor use-case:

use SugarCraft\Pty\PtySystemFactory;
use SugarCraft\Pty\Posix\MultiPump;

$system = PtySystemFactory::default();
$mp     = new MultiPump();

// Register two shells and tee each master to stdout with a prefix.
foreach ([0, 1] as $idx) {
    $pair   = $system->open(80, 24);
    $prefix = "[session-{$idx}] ";
    $sink   = fopen('php://memory', 'w+b');

    $child = $pair->slave()->spawn(
        ['/bin/bash', '-i'],
        ['TERM' => 'xterm-256color'],
        80, 24,
        controllingTerminal: true,
    );

    $mp->add($pair->master(), $sink, $child);
}

// Drive the multiplexer until every session exits.
while (!$mp->allDone()) {
    $drained = $mp->tick();
    if ($drained > 0) {
        // Drain each per-session sink, tag with prefix, print.
    }
}

See examples/multi-pump.php for the full interactive demo — two shells teed to stdout with per-session prefixes, quit on Ctrl-C.

Pump callbacks

PumpOptions exposes four optional callbacks covering the pump loop lifecycle, plus a sshDefault() named constructor for SSH-session-tuned behaviour:

Callback Fires when Use for
keepalive idle tick (stream_select → 0) Legacy heartbeat alias; prefer onIdle
onIdle (step 01.03) idle tick — every stream_select timeout Keepalive pings, polling housekeeping, any periodic task
onSigwinch terminal dimensions change Forward host resize into child's TIOCSWINSZ
onChildExit child process exits Resource cleanup, notification

PumpOptions::sshDefault(): self — SSH-session-tuned preset. Returns a PumpOptions with the same defaults (chunkBytes, selectTimeoutUs, flushDeadlineSec, stdinEofGraceSec, veof) previously hardcoded in InProcessTransport. Use when you need SSH-session behaviour outside the candy-wish transport layer:

$opts = PumpOptions::sshDefault()->withOnIdle(fn () => error_log('alive'));

onIdle vs onSigwinch: These are independent hooks. onIdle fires on every idle tick (no I/O ready). onSigwinch fires only when MasterPty::size() differs from the last known size — driven by the consumer's SignalForwarder callback. Do not conflate them: if you were passing onSigwinch(0, 0) as a fake idle tick, migrate to onIdle (step 01.03).

use SugarCraft\Pty\Posix\PosixPump;
use SugarCraft\Pty\PumpOptions;

$opts = (new PumpOptions())
    ->withOnIdle(fn () => error_log('still alive'))        // every ~50 ms
    ->withOnSigwinch(fn (int $cols, int $rows) => null)   // on dimension change
    ->withOnChildExit(fn (int $code) => error_log("exited $code"));

(new PosixPump())->run($master, STDIN, STDOUT, $child, $opts);

Examples

  • examples/spawn-bash.php — The simplest end-to-end slice: PtySystemFactory::default()->open()$pair->slave()->spawn(['bash', ...]) → drain master → reap. Start here.
  • examples/pump-output.php — Long-running counter; demonstrates non-blocking read with timeout, line-by-line pumping.
  • examples/resize-forwarding.php — Wire SignalForwarder to deliver host SIGWINCH into the child PTY's TIOCSWINSZ; observe the child's tput cols / lines flip mid-stream.

Library lookup

Defaults: libc.so.6 on Linux, /usr/lib/libSystem.B.dylib on macOS. Override via the SUGARCRAFT_LIBC env var for unusual setups (musl, Alpine, custom sysroots).

Mirrors

Charm symbol candy-pty
xpty.Open() Pty::open()
xpty.Pty.Start(cmd) Pty::spawn(cmd, env, cols, rows)
xpty.Pty.Read(buf) Pty::read($len, $timeout)
xpty.Pty.Write(buf) Pty::write($bytes)
xpty.Pty.Resize(cols, rows) Pty::resize(cols, rows)
xpty.Pty.Size() Pty::size()
signalpty.NotifyResize(c, pty) SignalForwarder::attachSigwinch($pty, $sizeProvider)

Compared to node-pty / creack/pty / portable-pty

Cross-ecosystem parity table for the dominant PTY libraries in Go, Rust, and Node.js. The goal is "as good as creack/pty on Linux and macOS" — not a kitchen-sink port. This table is deliberately honest about gaps: Windows ConPTY, foreground-job control, and worker-thread support are flagged as planned-or-missing rather than papered over.

Feature candy-pty creack/pty (Go) portable-pty (Rust) node-pty (Node.js)
Open / close PTY pair PtySystemFactory::default()->open() pty.Open() native_pty_system().openpty() pty.spawn()
Master read MasterPty::read($len, $timeout) Pty.Read([]byte) MasterPty::try_clone_reader() pty.onData()
Master write MasterPty::write($bytes) Pty.Write([]byte) MasterPty::take_writer() pty.write()
Resize (TIOCSWINSZ) MasterPty::resize($cols, $rows) pty.Setsize() MasterPty::resize() pty.resize()
Get size (TIOCGWINSZ) MasterPty::size() pty.GetsizeFull() MasterPty::get_size() ⚠️ via cols/rows props
Slave device path SlavePty::path() Pty.Name() SlavePty::as_raw_fd() ❌ hidden
Child spawn on slave SlavePty::spawn($cmd, $env, ...) pty.Start(cmd) SlavePty::spawn_command() pty.spawn(file, args)
Controlling terminal (TIOCSCTTY) ✅ opt-in via controllingTerminal: true ✅ implicit in Start() ✅ implicit in spawn_command() ✅ implicit
Termios raw mode Termios::makeRaw() (FFI + stty fallback) ✅ via golang.org/x/term Termios in nix crate ⚠️ caller's responsibility
Termios get / restore Termios::current() / restore() term.MakeRaw() / Restore() Termios::set_termios() ❌ caller's responsibility
SIGWINCH forwarding SignalForwarder::attachSigwinch() pty.InheritSize() ⚠️ caller wires signal handler ✅ implicit
Exit code retrieval Child::wait() / exitCode() cmd.Wait() / ProcessState Child::wait() pty.onExit()
Signal injection (SIGINT/TERM/KILL) Child::kill($signal) cmd.Process.Signal() Child::kill() pty.kill(signal)
EOF / VEOF handling PosixPump writes VEOF on stdin EOF ⚠️ caller drives termios ⚠️ caller drives termios ⚠️ caller writes \x04
Non-blocking master I/O setBlocking(false) + stream_select os.File non-blocking set_nonblocking() ✅ event-driven
Byte pump abstraction PosixPump::run() w/ EOF grace + keepalive ❌ caller copies bytes ❌ caller copies bytes ✅ libuv-driven
Async / threaded operation ⚠️ single-loop pump only (ReactPHP-friendly) ✅ goroutines built-in ✅ thread / async runtime ✅ libuv worker thread
Windows ConPTY ❌ planned (v2 sidecar; see plans/x-windows.md) ❌ Linux/macOS only ConPtySystem winpty / ConPTY
Dependency-free DI seam Contract\PtySystem interface ❌ concrete *Pty only PtySystem trait ❌ concrete bindings

Legend: ✅ shipping today · ⚠️ partial / opt-in / caller-driven · ❌ not implemented. Method names cite real upstream symbols — see docs/CONCEPTS.md for the porting rationale behind each row.

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 (echo, tput, bash -c '…') don't benefit.

Architecture

Backend selection

PtySystemFactory::default() respects the SUGARCRAFT_PTY_BACKEND environment variable to select which PTY backend to use:

Value Behaviour
(unset) / auto Platform-appropriate default (same as posix-ffi on POSIX)
posix-ffi PosixPtySystem — FFI into libc posix_openpt etc. (Linux / macOS)
sidecar Not implemented in v1 — throws UnsupportedPlatformException (deferred to phase 12)
pecl Not implemented in v1 — throws UnsupportedPlatformException (deferred to phase 12)

Unrecognised values throw \InvalidArgumentException naming the valid options.

The termios backend follows the same pattern via SUGARCRAFT_TERMIOS (posix-ffi / stty).

Library layout

The library is organised in two layers:

Contract interfaces (src/Contract/) — pure signatures, no logic

Contract Upstream mirror Upcoming POSIX implementation
PtySystem portable-pty.PtySystem PosixPtySystem
PtyPair portable-pty.PtyPair PosixPtyPair
MasterPty creack/pty.Pty / portable-pty.MasterPTY PosixMasterPty
SlavePty creack/pty.Pty / portable-pty.SlavePty PosixSlavePty
Child creack/pty.Cmd / portable-pty.Process PosixChild
Process creack/pty.Cmd (non-PTY spawn) PosixProcess
Termios portable-pty.Termios PosixTermios / SttyTermios
Pump candy-wish.InProcessTransport PosixPump

POSIX implementation (src/Posix/) — implementation layer

The Posix* classes implement the contracts above using Linux/macOS syscalls: FFI into libc for posix_openpt/grantpt/unlockpt/ptsname_r, proc_open for child spawning, ioctl(TIOCSWINSZ/TIOCGWINSZ) for resize, and FFI::cdef() tcgetattr/tcsetattr/cfmakeraw for termios. The factory TermiosFactory selects PosixTermios (FFI) when ext-ffi is available and falls back to SttyTermios (shell-out stty) otherwise.

Known limitations

  • Linux + macOS only. Windows ConPTY is a separate port.

License

MIT — see LICENSE.