Skip to content

Latest commit

 

History

23 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pty-rs

Spawn a child process on a real pseudo-terminal, built entirely on the Rust standard library. Zero dependencies, no libc/windows crates: the OS boundary is this crate's own extern blocks. ConPTY (CreatePseudoConsole, Windows 10 1809+) on Windows; the classic POSIX pty (posix_openpt/grantpt/unlockpt + setsid/TIOCSCTTY) on Unix.

A process on a PTY believes it is talking to a real terminal: it enables colors, draws progress UIs, and emits the VT byte stream a terminal would receive. That stream is exactly what this crate hands you: parse it with the ansi crate, or pipe it on. This is the enabler for hosting interactive CLIs (a coding agent, a shell) inside another program.

Usage

use std::time::Duration;

let mut pty = pty::Pty::spawn("cmd", &["/C", "echo hello"], 24, 80)?;
let mut buf = [0u8; 4096];
while let Some(n) = pty.read_timeout(&mut buf, Duration::from_secs(2))? {
    if n == 0 { break; } // the terminal closed
    handle_vt_bytes(&buf[..n]);
}
pty.write(b"like keystrokes\r\n")?;   // to the child's terminal input
pty.resize(40, 120)?;                 // the child sees the new size
let code = pty.wait()?;               // also: try_wait(), kill()
# fn handle_vt_bytes(_: &[u8]) {}
# std::io::Result::Ok(())

Semantics, stated plainly:

  • read_timeout → None timeout / Some(0) end-of-stream / Some(n) data. End-of-stream timing is platform-dependent (some Windows builds keep the console open until drop); output written before exit is always drainable after wait(); drain with short timeouts.
  • Exit codes: a Unix child killed by a signal reports 128 + signo.
  • Dropping a Pty releases the terminal but, like std::process::Child, does not kill the child.
  • spawn_with_env(cmd, args, rows, cols, env) injects per-child environment variables: the child sees the parent environment merged with env (overrides win by key), so PATH/SystemRoot survive while a credential can be set for one child alone. spawn is spawn_with_env with no overrides.
  • spawn_full(cmd, args, rows, cols, env, cwd) adds a per-child working directory: cwd = Some(dir) starts the child in dir (so a spawned agent auto-loads the CLAUDE.md in its own tree), None inherits the parent's cwd. A nonexistent cwd is returned as an io::Error: no silent fallback. spawn and spawn_with_env are spawn_full with cwd = None.
  • reader() returns a PtyReader (Send, io::Read) whose read blocks until output arrives, for a thread of its own: a host watching many terminals wakes the moment any of them writes instead of polling each. Use a reader or read_timeout, not both. Ok(0) is end of stream (on unix also once the Pty is dropped). On Windows keep reading, or drop the reader, before dropping the Pty.
  • spawn_suspended(cmd, args, rows, cols, env, cwd) + resume() (Windows only) create the child with CREATE_SUSPENDED: it has a pid but runs nothing until resumed, so a caller can put it in a Job Object before it can start children of its own. A child never resumed stays suspended until killed. Unix has no equivalent: std's spawn returns only after exec.

Platform notes (the hard-won bits)

  • Windows: the child is bound to the pseudoconsole via the PSEUDOCONSOLE proc-thread attribute, and, crucially, STARTF_USESTDHANDLES with NULL handles, without which a child whose parent has redirected stdio (a test harness, a service) writes past the pty entirely. Timeouts poll PeekNamedPipe (anonymous pipes can't do overlapped I/O); ERROR_BROKEN_PIPE is end-of-stream. Command lines are built with proper argv quoting (unit-tested against the CommandLineToArgvW rules), except for a .bat/.cmd target (an npm CLI shim, say): that runs under the system cmd.exe with batch-safe argument encoding, so &, | and %VAR% in an argument arrive intact, and an argument containing CR, LF or NUL is an error.
  • Unix: the child is spawned with std::process::Command over the slave fd, made session leader + controlling tty in pre_exec; the master carries FD_CLOEXEC so it never leaks into children. Timeouts use poll; EIO from a closed slave is end-of-stream. Linux is exercised (run locally); macOS constants are in place and compile-checked, not yet runtime-verified.

What's deliberately out of scope

Multiplexing, layout, scrollback, and session persistence: the atrium product's concerns. Per-child env injection (spawn_with_env) and a per-child working directory (spawn_full) are supported; everything above is not. One child, one PTY, bytes in, bytes out.

Correctness

The integration tests run real children on real PTYs, deadline-bounded: output arrives through the terminal, written input reaches an interactive shell and its response comes back, resize succeeds and the child observes a tty, exit codes propagate (twice-wait stable), try_wait transitions after kill (and killing an exited child is a no-op), pre-exit output is drainable post-exit, and spawning a missing program returns an error (that this path leaks no handles comes from the spawn code's cleanup, not from a check in the suite). Windows runs these locally; the suite is portable and passes on Linux too.

Development

python dev.py check   # zero-dependency guard + cargo test (the pre-push gate)

About

Spawn a child process on a real pseudo-terminal (ConPTY / POSIX pty) via own FFI - Rust stdlib only, zero dependencies

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages