PTY utilities (ftui-pty)
ftui-pty is the crate FrankenTUI uses to test itself against a real
terminal without corrupting the parent tty. It spawns subprocesses against a
pseudo-terminal, feeds them synthetic input, parses the output through a
virtual terminal state machine, and exposes the result as structured data that
a test can assert against.
ftui-pty is not a runtime backend. It does not implement the
ftui-backend traits. It is a test fixture that drives other
programs. If you are writing a FrankenTUI app, you almost certainly do
not depend on this crate.
What it is for
Most widget and runtime tests run in-process against the ftui-harness
simulator. That is fast, deterministic, and covers the bulk of behavior. But
some things only show up when a real OS pipe is involved:
- Cleanup on panic. Does
TerminalSessionrestore cooked mode when the child panics inside raw mode? - Signal handling. Does
SIGWINCHin a child subprocess propagate? - Actual shells. Does the inline-mode output interleave correctly with
real
bash/zshscrollback? - External tools. Does the output work when piped through
lessor captured bytmux?
ftui-pty gives each of those a controlled, scripted, scrapable fixture.
The core modules
- lib.rs
- pty_process.rs
- virtual_terminal.rs
- input_forwarding.rs
- ws_bridge.rs
PTY process management — pty_process.rs
pub struct PtyConfig {
pub cols: u16,
pub rows: u16,
pub term: Option<String>, // default: "xterm-256color"
pub env: Vec<(String, String)>,
pub test_name: Option<String>, // for log tagging
pub log_events: bool,
pub input_write_timeout: Duration,
}Operations:
| Call | Purpose |
|---|---|
spawn(cmd, config) | Fork a shell subprocess on the slave side of a new PTY. |
write_input(bytes) | Send input to the child, capped by input_write_timeout. |
read_output(opts) | Drain output with retry/timeout; configurable via ReadUntilOptions. |
is_alive() | Non-blocking liveness check via waitpid(WNOHANG). |
kill() | Best-effort SIGTERM → SIGKILL escalation. |
Dropping the handle closes the master fd, which sends the child SIGHUP. No
tests leak processes — assuming the test process itself does not crash.
Virtual terminal — virtual_terminal.rs
An in-process terminal state machine. Feed it bytes, and it parses ANSI escape sequences, maintains a cell grid, tracks cursor state, and lets the test inspect the grid directly:
let mut vt = VirtualTerminal::new(80, 24);
vt.feed(output_bytes);
assert_eq!(vt.cell_at(0, 0).char, 'H');
assert_eq!(vt.cursor(), (10, 5));Used when you want to test output fidelity without spawning a process at
all — for example, in ftui-tty unit tests that round-trip a frame through
the presenter and then through the VT and compare.
Input forwarding — input_forwarding.rs
Converts ftui_core::event::KeyCode values to the ANSI sequences a real
terminal would send:
KeyCode::Up→ESC [ AKeyCode::Home→ESC [ HKeyCode::Enter→\rKeyCode::Char('a')+Ctrl→0x01- Bracketed-paste blocks wrapped with
ESC [ 200 ~/ESC [ 201 ~
This is the inverse of the parser that lives in ftui-core. Having both
sides in one workspace means keystroke round-tripping is trivially testable.
WebSocket bridge — ws_bridge.rs
A bidirectional adapter that tunnels a PTY session over a WebSocket using
the external frankenterm-core framing codec:
frankenterm-core = { version = "0.2.0", features = ["ws-codec"] }- Client side (browser): sends input JSON, receives output frames.
- Server side (this crate): spawns the child, forwards output through the codec, forwards incoming input.
The bridge exists for remote-development testing — running a FrankenTUI
binary inside a container and driving it from a browser page. The binary
frankenterm_ws_bridge wires this up into a standalone server.
ReadUntilOptions
pub struct ReadUntilOptions {
pub timeout: Duration,
pub max_retries: u32,
pub retry_delay: Duration,
pub min_bytes: usize,
}The reason this exists: subprocess output is bursty. A single TUI frame can
arrive in 12 separate read() chunks over 40ms as the kernel’s pipe buffer
drains. Tests that just read once usually miss data. read_until keeps
pumping until either the total byte count clears min_bytes, or the timeout
fires, or max_retries is exhausted. log_events: true dumps the retry
decisions to stderr for debugging flaky assertions.
The bin/ helpers
frankenterm_ws_bridge— long-lived server for remote-testing workflows.pty_canonicalize— rewrites captured PTY output into a deterministic form for snapshot comparison (scrubs timestamps, SGR resets, cursor-position queries, etc.). Used by the determinism soak tests.
Example: asserting a cleanup invariant
Spawn a child running a FrankenTUI program
let cfg = PtyConfig {
cols: 80, rows: 24,
term: Some("xterm-256color".into()),
log_events: true,
..PtyConfig::default()
};
let mut pty = PtyProcess::spawn("target/debug/my-ftui-app", &cfg)?;Send a keystroke that the app expects to crash on
pty.write_input(b"panic\n")?;Read until the process exits, via the virtual terminal
let output = pty.read_output(&ReadUntilOptions {
timeout: Duration::from_secs(2),
max_retries: 40,
retry_delay: Duration::from_millis(25),
min_bytes: 4,
})?;
let mut vt = VirtualTerminal::new(80, 24);
vt.feed(&output);Assert the terminal was restored to cooked mode
// e.g. cursor visible, alt-screen exited, raw mode off
assert!(!vt.is_in_alt_screen());
assert!(vt.cursor_visible());The entire ftui-harness E2E matrix is a variation on this pattern.
External frankenterm-core — adjacent, not vendored
The WebSocket bridge uses the external frankenterm-core crate as a
dependency. That crate is developed separately; crates/frankenterm-core
is not in this workspace. See the integration
note for the full picture.
Pitfalls
- Do not share a
PtyProcessacross threads without guarding it. The struct serializes reads and writes through the master fd; concurrent reads are not supported. - Do not forget to set
term. The child inspects$TERMto decide which capability to emit. Leaving it unset produces wildly different output on different hosts. - Do not rely on
read_outputreturning everything in one call withoutReadUntilOptions. It is the single most common cause of flaky tests in this codebase. - This crate is test-only. Do not re-export its types from a production binary’s public API.
See also
- Platforms overview — where the test fixture fits
- Web backend — the other in-tree backend
- FrankenTerm integration — the WS bridge’s other half
- Testing overview · Contributing — dev loop · Demo showcase overview