Skip to Content
PlatformsPTY utilities

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 TerminalSession restore cooked mode when the child panics inside raw mode?
  • Signal handling. Does SIGWINCH in a child subprocess propagate?
  • Actual shells. Does the inline-mode output interleave correctly with real bash / zsh scrollback?
  • External tools. Does the output work when piped through less or captured by tmux?

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:

CallPurpose
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 SIGTERMSIGKILL 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::UpESC [ A
  • KeyCode::HomeESC [ H
  • KeyCode::Enter\r
  • KeyCode::Char('a') + Ctrl0x01
  • 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 PtyProcess across 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 $TERM to decide which capability to emit. Leaving it unset produces wildly different output on different hosts.
  • Do not rely on read_output returning everything in one call without ReadUntilOptions. 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