sugarcraft / candy-pty
PHP port of charmbracelet/x/xpty — pseudo-terminal abstraction (open / spawn / resize) for SugarCraft. Linux + macOS via FFI to libc.
Requires
- php: ^8.3
- ext-ffi: *
- react/event-loop: ^1.6
- react/promise: ^3.3
- sugarcraft/candy-ansi: dev-master
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-input: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/candy-testing: dev-master
- sugarcraft/candy-vcr: dev-master
- sugarcraft/candy-vt: dev-master
Suggests
- ext-pcntl: Enables cleaner SIGCHLD reaping and signal-driven resize forwarding; the lib polls waitpid() when pcntl is absent.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 01:55:42 UTC
README
candy-pty — pseudo-terminal primitives for PHP 8.3+, the building blocks for
driving child processes inside terminal UIs. 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 not covered here.
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 — Child::kill() signals
through Libc::kill(), which uses posix_kill() when it is loaded and
libc kill(2) over FFI otherwise.
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::new(); // 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(int $cols=80, int $rows=24): Pty |
Delegates to PosixPtySystem::open(), so the facade shares the canonical path exactly: on Darwin openpty() first (single call), falling back to the quartet on -1; on Linux posix_openpt + grantpt + unlockpt + ptsname_r. The master fd is FD_CLOEXEC (children never inherit it, so closing it delivers the hangup) and the requested winsize is applied at open. 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. TIOCSWINSZ is applied before the spawn (throws, 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 so the child claims the slave PTY as its ctty (Ctrl+C → SIGINT, job control); requires ext-pcntl. |
$pair->slave()->spawn(array $cmd, ?array $env, ?int $cols=null, ?int $rows=null, bool $controllingTerminal=false): Child |
Window size is a property of the pty, not of the controlling-terminal relationship, so an explicit $cols/$rows is applied on every spawn (before it, throwing with no child on failure, then re-asserted best-effort after it for macOS) — like creack/pty.StartWithSize(). Omit both to keep the size the pair was opened or last resized with (creack/pty.Start(), portable-pty spawn_command()); pass one to keep the other half. The parent's slave handle is opened O_CLOEXEC, so the child holds the slave only at fds 0-2. |
$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. fcloses the stream() wrapper if one was materialised, then always close(2)s the original master fd via FFI. Throws PtyException if that close(2) fails (on either path) or if the fclose failed — in the latter case only after the master fd itself was released. |
$child->pid: int |
OS process id. |
$child->wait(): int |
Waits via waitpid FFI (sub-ms) first, falls back to 10ms proc_get_status poll. Returns exit code (signal death = 128+signal). Idempotent. |
$child->exited(): bool |
Non-blocking probe. |
ControllingTerminal::claim(int $fd): void |
Calls setsid() + ioctl(TIOCSCTTY) to claim $fd as the session's controlling terminal. Used internally by the controllingTerminal: true shim path; callable directly from FFI-heavy contexts that want the same effect without the PHP shim. Throws PtyException on failure. |
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).
For callers that hold a raw /dev/tty fd (no MasterPty object),
attachSigwinchToFd() calls TIOCSWINSZ directly on the fd:
use SugarCraft\Pty\SignalForwarder; use SugarCraft\Pty\Libc; $ttyFd = Libc::lib()->open('/dev/tty', 0x0002); // O_RDWR SignalForwarder::attachSigwinchToFd( $ttyFd, fn () => Tty::size(), fn (int $cols, int $rows) => null, // optional post-resize callback );
Both methods default 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 bytes (recordInputBytes) as they
are delivered to the master — bytes parked behind a full master are
recorded when they land, not when they were read — and master-read
chunks (recordOutput) on the iteration that reads them. 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::new(); $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.
Async (ReactPHP)
Every blocking primitive has an event-loop counterpart, so a ReactPHP application can drive PTY sessions without stalling its loop:
ReactPump— the asyncPosixPump. Registers the PTY master (and optional host stdin) with the loop viaaddReadStream(); returns a promise that resolves with the sync pump's exit-code contract (exit code /0no-child /-1still-running-at-teardown).PosixChild::waitAsync()— promise-returningwait(). Periodic non-blockingexited()poll (FFIwaitpid(WNOHANG)fast path); cancelling the promise cancels its poll timer only.MultiPump::runAsync()— promise-returningrun(). OneReactPumpper session; resolves with the session-id → exit-code map once every child exits / every master EOFs.
use React\EventLoop\Loop; use SugarCraft\Pty\Posix\ReactPump; use SugarCraft\Pty\PtySystemFactory; use SugarCraft\Pty\PumpOptions; $pair = PtySystemFactory::new()->open(80, 24); $child = $pair->slave()->spawn(['/usr/bin/top'], ['TERM' => 'xterm-256color'], 80, 24); $pump = new ReactPump(); // Loop::get() by default; injectable $pump->start( $pair->master(), stdinStream: STDIN, // optional — forwarded to the child stdoutStream: STDOUT, // optional — receives child output child: $child, opts: new PumpOptions(), onData: fn (string $bytes) => null, // optional tap on every output chunk )->then(function (int $exit) use ($pair): void { echo "child exited: {$exit}\n"; $pair->master()->close(); }); // Meanwhile the loop stays free for timers, sockets, other sessions… $child->waitAsync()->then(fn (int $code) => error_log("reaped: {$code}")); Loop::run();
ReactPump::stop() detaches every loop registration (read/write
streams + timers) and resolves the pending promise — nothing leaks
into the loop. All loop-accepting APIs take an optional
LoopInterface so tests and multi-loop apps can inject their own.
Child-exit detection polls exited() on a timer (at the
selectTimeoutUs cadence for the pump, 15 ms default for
waitAsync()) instead of trapping SIGCHLD: signal dispositions are
process-global, so a handler would conflict with user code and with
SignalForwarder, and ext-pcntl is not guaranteed to be loaded.
The blocking APIs (PosixPump::run(), Child::wait(),
MultiPump::run()) are unchanged — the async layer is purely
additive.
Pump back-pressure
When the master accepts only part of a stdin chunk (the child is not
reading fast enough), PosixPump parks the remainder, stops reading
stdin, and watches the master for writability until the remainder
drains — so input is never reordered, never grows without bound, and
is delivered even after stdin has hit EOF. VEOF is sent only behind
the last data byte. While a remainder is still draining after stdin
EOF, every successful write restarts the stdinEofGraceSec window: the
grace is "this long with no progress", not a cut-off mid-delivery.
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::new()->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— WireSignalForwarderto deliver host SIGWINCH into the child PTY's TIOCSWINSZ; observe the child'stput cols / linesflip 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).
Environment variables
Every environment variable this package reads, in one place. The table is
CHECKED, not proof-read: tests/EnvRosterTest.php token-scans src/, bin/
and tests/ and reds in both directions — a variable read with no row here,
and a row here nothing reads.
| Variable | Read by | Effect |
|---|---|---|
SUGARCRAFT_LIBC |
Libc |
Path to the C library to bind FFI against, instead of the platform default (libc.so.6 on Linux, /usr/lib/libSystem.B.dylib on macOS). For musl, Alpine and custom sysroots. |
SUGARCRAFT_PTY_BACKEND |
PtySystemFactory::new() |
Which PTY backend to build: unset / auto, posix-ffi, sidecar, pecl. Unrecognised values throw \InvalidArgumentException. See Backend selection. |
SUGARCRAFT_TERMIOS |
TermiosFactory |
stty forces the subprocess termios backend instead of the FFI one. Anything else leaves the platform default in place. |
CANDY_PTY_HANG_BUDGET |
tests/Support/HangWatchdog |
Seconds a single test may run before the suite's own watchdog turns a wedged test into a named failure plus a forensic bundle. 0 opts the watchdog out entirely; unset uses the built-in budget. Test suite only — it configures nothing in src/, and it is here because a contributor chasing a hang needs to find it. |
SUGARCRAFT_LIBC and SUGARCRAFT_TERMIOS are also read by the test suite,
which sets and restores them around the cases that exercise each branch.
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 reimplementation. 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::new()->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 | ❌ not yet implemented | ❌ 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 PTY primitives — see
docs/CONCEPTS.md for the design 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. The setsid() + ioctl(TIOCSCTTY) logic
is also available directly as ControllingTerminal::claim(int $fd) —
usable from FFI-heavy contexts that want the same effect without the
shim overhead. 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::new() 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 |
pecl |
Not implemented in v1 — throws UnsupportedPlatformException |
Unrecognised values throw \InvalidArgumentException naming the
valid options.
The termios backend follows the same pattern via SUGARCRAFT_TERMIOS
(posix-ffi / stty).
Platform behaviour — openpty vs quartet
On macOS (Darwin), PosixPtySystem::openPtyMaster() attempts openpty(3)
first — a single libc call that opens both master and slave and returns
both file descriptors. If it returns -1 (symbol unavailable on a given
Darwin build), the code falls back to the standard POSIX quartet:
posix_openpt + grantpt + unlockpt + ptsname_r.
On Linux, the quartet is used directly. The openpty symbol on Linux
lives in libutil.so.1 rather than libc.so.6; Libc::libutil() loads
it as a fallback specifically for this symbol. Most Linux systems have
both, so the Darwin-first path is not normally exercised on Linux.
This behaviour is internal to PosixPtySystem and requires no consumer
action — Pty::open() abstracts it away completely.
Library layout
The library is organised in two layers:
Contract interfaces (src/Contract/) — pure signatures, no logic
| Contract | Counterpart primitive | 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. Every
fallback is logged via error_log with its ordinal and the FFI failure
reason (not just the first one per process), and
TermiosFactory::fallbackCount() reports the running total.
Shared foundations
candy-pty builds on two sibling libraries for input/output escape-sequence handling:
- candy-input —
EscapeDecoderconsumes raw bytes from the PTY master and emits keyboard/mouse/osc events. The pty-read loop feedsPty::read()chunks directly intoEscapeDecoder; no private arrow-key or Ctrl-key buffering. - candy-ansi —
Parserhandles ANSI escape sequences in PTY output when inspection is needed (cursor-position reports, SGR state, OSC strings). PTY output that only needs pass-through to the terminal skips this layer entirely.
Known limitations
- Linux + macOS only. Windows ConPTY is not yet supported.
- ext-ffi is required but disabled by default.
ext-ffiis loaded eagerly becauseFFI::cdef()must resolve symbols at script startup. Many Linux distributions ship PHP withffi.enable=0inphp.ini(a security hardening measure) which causes candy-pty to throwPtyException: Failed to load libc. To fix: edit yourphp.iniand setffi.enable=1(orffi.enable=peerfor stricter CSP), then restart your web server or PHP-FPM process. In CLI context you can also set it inline:php -d ffi.enable=1 your-script.php. /dev/ptmxaccess is required. EveryPosixPtySystem::open()asks the kernel for a new PTY master viaposix_openpt(), which needs a readable and writable/dev/ptmxwith a mounteddevptsbehind it. Minimal or locked-down containers (scratch/distroless images, sandboxes with a read-only or masked/dev, hosts where/dev/ptmxis0600 root:root) fail withPtyException: posix_openpt() failed (rc=-1, errno=...)— typically2 (No such file or directory)when the node is missing or13 (Permission denied)when it is inaccessible. Remediation, in order of preference: ensuredevptsis mounted (mount -t devpts devpts /dev/pts; Docker and Podman do this by default unless/devis overridden), fix the node's permissions (chmod 666 /dev/ptmx— the standard mode on Linux), or as a last resort run the container privileged. The test suite skips PTY tests automatically on such hosts rather than failing.- Darwin arm64 resizes shell out to
stty. On macOS arm64 the real libcioctlis variadic — varargs go on the stack while our fixed-arg FFI cdef puts the winsize pointer in a register — soioctl(TIOCSWINSZ)fails andSizeIoctlfalls back to spawningstty -f /dev/fd/<fd> rows R cols Cper resize. The fallback is correct but costs a subprocess per size change; identical resizes within a 20 ms window are rate-limited (skipped) so SIGWINCH bursts don't fork a process each. Expect resize on Darwin arm64 to be milliseconds, not microseconds — reads viaioctl(TIOCGWINSZ)are unaffected. This goes away once macOS ships POSIX 2024tcsetwinsize.
License
MIT — see LICENSE.
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.