PTY¶
cl-tty-kit includes a minimal pseudoterminal (PTY) abstraction for spawning a
child process attached to a terminal and exchanging bytes with it. It is the
foundation for things like a shell wrapper, a test harness that drives an
interactive program, or a terminal multiplexer.
Two layers sit side by side:
- a stream layer — the
ptystruct plus character-oriented read/write and lifecycle helpers, convenient for ordinary use; and - an fd layer — the bare master file descriptor plus byte-transparent octet
I/O, for multiplexer-style
select(2)loops over many descriptors.
SBCL only
PTY support is part of the OS-facing, SBCL-specific layer and uses
sb-ext:run-program. cl-tty-kit requires SBCL throughout, so make-pty
and close-pty are available on every build that loads. See
Compatibility.
make-pty does not sanitize PROGRAM, ARGS, or ENVIRONMENT
make-pty spawns with sb-ext:run-program :search t, so PROGRAM is
resolved against PATH and ARGS / ENVIRONMENT pass straight through
to the spawned process. This is the intended API surface, but it means a
caller who forwards attacker-influenced strings into PROGRAM, ARGS, or
ENVIRONMENT is choosing to let that data drive process execution.
make-pty does not sanitize them — validate or constrain any untrusted
input yourself before passing it in. See the org security policy if
building a service around this.
The pty struct¶
pty is a small struct with two slots, read via pty-process and pty-stream:
pty-process— the underlyingsb-extprocess object, ornilfor a stream-only PTY or afterclose-ptyhas cleared it.pty-stream— the bidirectional character stream connected to the child.
You normally create one with make-pty rather than the raw constructor.
Spawning and lifecycle¶
make-pty spawns a program under a fresh PTY and returns a pty. All arguments
are keywords:
(make-pty) ; defaults to /bin/sh
(make-pty :program "cat")
(make-pty :program "env" :environment '("TERM=dumb" "LANG=C"))
(make-pty :program "ls" :args '("-la") :directory "/tmp")
:program is a string resolved against PATH, :args a list of strings,
:environment nil or a list of "NAME=value" strings, and :directory a
string or pathname (or nil). Transient "resource temporarily unavailable"
spawn failures are retried a few times internally before giving up.
The lifecycle is: make-pty → drive it → observe exit with pty-alive-p /
pty-exit-code → close-pty.
pty-alive-preturns true while the child is running,nilonce it has exited or when the PTY has no process. Use it in a read loop to tell "no data yet" from "the child exited".pty-exit-codereturns the child's integer exit code, ornilwhile it is still running. Read it afterpty-alive-pturnsniland beforeclose-ptyclears the process slot: 0 means success, non-zero is the failure status.close-ptyshuts the child down (closing the stream, then escalating throughSIGTERM/SIGKILLif it does not exit) and returns thepty.
close-pty is idempotent and always clears its slots
close-pty clears the stored process and stream even if shutdown itself
signals pty-operation-failed — by that point the stream is already
closed at the OS level, and leaving the slots populated would let a caller
re-close an already-closed stream or retry a shutdown that already gave up.
Calling close-pty again on an already-closed PTY is a harmless no-op.
Reading and writing (stream layer)¶
pty-write sends data to the child; pty-read pulls available output back.
(let ((pty (make-pty :program "cat")))
(pty-write pty "hello") ; accepts a string...
(pty-write pty #(104 105 10)) ; ...or a vector of octets (UTF-8 decoded)
(pty-read pty) ; => "hello\nhi\n" (whatever is ready), or NIL
(close-pty pty))
pty-write accepts a string or an octet vector and always flushes, returning the
pty. pty-read reads up to limit characters (default 4096) without blocking
and returns a string when data is available, or nil when nothing is ready —
so a poll loop can distinguish "no data yet" (nil plus pty-alive-p true) from
"child gone" (nil plus pty-alive-p false).
A common pattern is to read until some marker appears, sleeping briefly between empty reads:
(let ((chunks '()))
(loop repeat 50
for chunk = (pty-read pty)
do (when chunk (push chunk chunks))
(sleep 0.01))
(apply #'concatenate 'string (nreverse chunks)))
Resizing¶
pty-resize tells the child its window changed by sending TIOCSWINSZ on the
PTY's descriptor — the child normally receives SIGWINCH and re-queries its
size. Forward this whenever your own window resizes:
It signals pty-operation-failed when the size cannot be set — for example on a
platform whose ioctl constant is unknown, or a stream without an accessible
descriptor.
Failure reporting: pty-operation-failed¶
All the stream operations signal a single structured condition,
pty-operation-failed, carrying:
pty-operation-failed-operation—:spawn,:read,:write,:resize,:close, or an fd-layer operation;pty-operation-failed-pty— theptyinvolved, ornil;pty-operation-failed-reason— the underlying condition.
Spawn failures report :spawn and use nil for the PTY slot, because no PTY
object was created yet:
(handler-case
(make-pty :program "/definitely/missing")
(pty-operation-failed (c)
(list (pty-operation-failed-operation c) ; => :SPAWN
(pty-operation-failed-pty c)))) ; => NIL
Read and write failures report :read or :write, including closed-stream cases
after shutdown.
The fd layer: multiplexer-style I/O¶
For a caller running its own select(2)/poll(2) loop over many descriptors —
a terminal multiplexer, say — the stream API is the wrong shape. The fd layer
exposes the raw master descriptor and byte-transparent octet I/O that coexist
with, and do not replace, pty-read/pty-write.
pty-fdreturns the integer master-side file descriptor backing the PTY's stream. Hand it to your event loop or to the octet functions below. It signalspty-operation-failedwhen the stream is closed or exposes no descriptor.pty-pidreturns the child's process id, ornilwhen there is no process.
(let* ((pty (make-pty :program "cat"))
(fd (pty-fd pty))
(pid (pty-pid pty)))
;; register FD in your own select(2) set, keyed by PID
...)
Byte-transparent octet I/O¶
fd-read-octets and fd-write-octets do exact-byte I/O on a bare integer fd
using (simple-array (unsigned-byte 8)) buffers, with no character decoding —
the precise octets are preserved.
(let ((buffer (make-array 4096 :element-type '(unsigned-byte 8))))
(let ((n (fd-read-octets fd buffer)))
(cond
((null n) ...) ; no data ready on a non-blocking fd — wait for select
((zerop n) ...) ; EOF — the peer closed the other end
(t ...)))) ; n bytes are at the front of BUFFER
fd-read-octets reads at most limit bytes (defaulting to, and capped at,
the buffer length) into the front of the buffer and returns:
- a positive count when bytes were read;
0at end of file (the peer closed the other end);nilwhen a non-blocking fd has no data ready (EAGAIN/EWOULDBLOCK) or the call was interrupted by a signal.
It never blocks a non-blocking fd; on a blocking fd it blocks until data, EOF, or
error, so gate it with select(2)/poll(2).
fd-write-octets writes an octet vector verbatim and returns the integer number
of bytes written:
It retries EINTR internally and loops over short writes, so on a blocking fd it
writes every byte and returns the full length. On a non-blocking fd it stops when
the kernel buffer fills (EAGAIN/EWOULDBLOCK) and returns a short count — send
the remaining octets once the fd is writable again.
A hard OS error in either function is wrapped in pty-operation-failed.
See also¶
- Terminal Session and Raw Mode — putting your own terminal into raw mode before relaying bytes to a child PTY.
- Conditions — the
pty-operation-failedaccessors. - Compatibility — the SBCL-only rationale.
- API Reference — the full exported symbol list.