Linux PTYs, macOS PTYs, and Windows ConPTY

Sections 1–5 assumed a Unix PTY. This chapter is the architectural comparison: how the three platforms differ, which differences leak into your code, and what a Windows port would actually require.

Windows support is not required for any implementation in this curriculum. Read this to understand the shape of the problem, and so that when you draw the terminal-pty boundary you know exactly what it is protecting you from.


The Three Models

 ══ LINUX ═════════════════════════════════════════════════════════════════
   /dev/ptmx  ──open──▶  master fd  ◀── your process
                            ║
                     ┌──────╨──────┐
                     │ N_TTY line  │  ← kernel; echo, canonical, signals
                     │ discipline  │
                     └──────╥──────┘
   /dev/pts/N  ◀── devpts filesystem, dynamically created
        │
        └── the child's fds 0/1/2

 ══ macOS ═════════════════════════════════════════════════════════════════
   /dev/ptmx  ──open──▶  master fd
                            ║
                     ┌──────╨──────┐
                     │ BSD tty     │  ← same CONCEPT, different implementation
                     │ line disc.  │
                     └──────╥──────┘
   /dev/ttysNNN  ◀── STATIC device nodes, pre-created at boot
        │
        └── the child's fds 0/1/2

 ══ WINDOWS (ConPTY) ══════════════════════════════════════════════════════
   CreatePseudoConsole(size, in_read, out_write, 0, &hPC)
        │
        ├── you get: two PIPE handles (in_write, out_read)
        │
        └── the OS spawns conhost.exe as a HOST PROCESS
                 │
                 ├── conhost translates between VT sequences and the
                 │   Win32 Console API
                 │
                 └── the child talks to a CONSOLE, not a tty

The fundamental difference: on Unix, the PTY is a kernel object with a line discipline. On Windows, ConPTY is a user-space service (conhost.exe) that translates between VT sequences and the legacy Console API. That is not a detail — it changes the failure modes, the ownership model, and what "the child's terminal" even means.


Linux vs. macOS: The Differences That Reach Your Code

AspectLinuxmacOS
Multiplexer device/dev/ptmx/dev/ptmx
Slave path/dev/pts/N (devpts, dynamic)/dev/ttysNNN (static nodes)
Number available/proc/sys/kernel/pty/max, typically 4096+Bounded by the static nodes (a few hundred)
ptsname_rYes (glibc)Present on recent versions; ptsname is the portable choice
read(master) after the child exitsEIOreturns 0 (EOF)
openpty header<pty.h>, link -lutil<util.h>, in libSystem
Pollingepoll (also poll, select)kqueue (also poll, select)
Signal-as-fdsignalfdkqueue EVFILT_SIGNAL
Child exit as an eventpidfd_open (modern)kqueue EVFILT_PROC
Process inspection/proc/<pid>/{fd,stat,status,wchan}lsof, sysctl, libproc
Syscall tracingstracedtruss (sudo; SIP-restricted for system binaries)
stty on another ttystty -F /dev/pts/Nstty -f /dev/ttysNNN
Default TERMDistribution-dependentxterm-256color
TIOCSCTTY arg typec_ulong on Linuxdiffers; always use the libc constant

The two that actually bite

1. EIO vs. EOF.

#![allow(unused)]
fn main() {
// This must be in your code, with a comment naming both platforms. It is not a
// portability nicety — getting it wrong means an error message on every normal
// exit (Linux) or an infinite loop (macOS).
match read_master(&mut buf) {
    Ok(0) => shutdown(),                                          // macOS: EOF
    Err(e) if e.raw_os_error() == Some(libc::EIO) => shutdown(),  // Linux: child gone
    Err(e) if e.kind() == ErrorKind::WouldBlock => {}
    Err(e) if e.kind() == ErrorKind::Interrupted => {}
    Ok(n) => process(&buf[..n]),
    Err(e) => return Err(e),
}
}

2. ioctl request types. TIOCGWINSZ has a different value and integer type across platforms and architectures. Hardcoding 0x5413 gives you a program that works on Linux/x86_64 and corrupts memory on aarch64 macOS. Always libc::TIOCGWINSZ, and prefer nix/rustix's typed wrappers once you have written the raw version once.

Everything else is genuinely the same

The line discipline, termios, sessions, process groups, controlling terminals, job control, SIGWINCH, SIGHUP — all identical in concept and nearly identical in API. Section 1's knowledge transfers wholesale.


Windows ConPTY

The API

/* 1. Create the pipes YOU will use. */
HANDLE in_read, in_write, out_read, out_write;
CreatePipe(&in_read, &in_write, NULL, 0);
CreatePipe(&out_read, &out_write, NULL, 0);

/* 2. Create the pseudoconsole. Give it the ends the CHILD will use. */
HPCON hpc;
COORD size = { 80, 24 };
CreatePseudoConsole(size, in_read, out_write, 0, &hpc);

/* 3. Attach it to a process via a thread attribute. */
STARTUPINFOEX si = { sizeof(si) };
SIZE_T bytes = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &bytes);
si.lpAttributeList = malloc(bytes);
InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &bytes);
UpdateProcThreadAttribute(si.lpAttributeList, 0,
                          PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
                          hpc, sizeof(hpc), NULL, NULL);

PROCESS_INFORMATION pi;
CreateProcessW(NULL, L"cmd.exe", NULL, NULL, FALSE,
               EXTENDED_STARTUPINFO_PRESENT, NULL, NULL, &si.StartupInfo, &pi);

/* 4. Read/write your ends with ReadFile/WriteFile. */
/* 5. Resize:  ResizePseudoConsole(hpc, newSize); */
/* 6. Teardown: ClosePseudoConsole(hpc);  then close handles, wait for the child. */

The conceptual mapping

UnixWindows ConPTY
posix_openpt + grantpt + unlockpt + ptsnameCreatePseudoConsole
master fdtwo pipe HANDLEs (read and write are separate)
slave /dev/pts/Nthe pseudoconsole handle, attached via a thread attribute
fork + setsid + TIOCSCTTY + dup2 + execveCreateProcessW with PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
ioctl(TIOCSWINSZ)ResizePseudoConsole
ioctl(TIOCGWINSZ)GetConsoleScreenBufferInfo (child side)
SIGWINCHNo signal. The console API notifies differently.
SIGINT from ^CGenerateConsoleCtrlEvent(CTRL_C_EVENT, ...)
SIGHUP on master closeClosing the pseudoconsole terminates the attached processes
termiosConsole modes: SetConsoleMode with ENABLE_VIRTUAL_TERMINAL_INPUT etc.
The line disciplineENABLE_LINE_INPUT / ENABLE_ECHO_INPUT in the console mode
poll/epoll/kqueueOverlapped I/O + IOCP, or a thread per handle
Sessions, process groups, tcsetpgrpNo equivalent. Job objects are related but different.
/dev/pts/N device pathNo path. There is no filesystem object.

The differences that matter architecturally

DifferenceConsequence
Two handles, not one fdYour Pty type cannot be a single OwnedFd. Abstract over "a readable thing and a writable thing."
No poll on handlesYou need overlapped I/O with IOCP, or a reader thread per handle. mio and tokio handle this; raw code does not.
No signalsJob control has no analogue. ^C is GenerateConsoleCtrlEvent, and there is no SIGTSTP.
conhost.exe is in the middleA third process you did not spawn, translating VT↔Console API. It has its own bugs, its own version skew, and its own performance profile.
The child may not speak VTA legacy program using the Console API works because conhost translates. Its output is generated by conhost, not by the program.
No /dev/pts/NNothing to stty, nothing to lsof, nothing to inspect. Debugging is materially harder.
Window size has no signalResize notification reaches the child through the console API, not SIGWINCH.
Different escape-sequence dialectconhost emits a subset, and its own quirks. Sequences you never see on Unix appear, and vice versa.

Note: ConPTY's design goal was compatibility, not fidelity. Legacy Windows console programs use the Console API (WriteConsoleOutput, SetConsoleCursorPosition) and know nothing about VT. conhost translates their API calls into VT sequences for you. That is why ConPTY exists and why it is a user-space process — and it is also why its output can differ from what any Unix program would emit for the same visual result.


What a Windows Port Would Require

If your boundaries are right, exactly one crate changes. That is the claim Section 5 makes; here is the check.

   terminal-protocol   NO CHANGE   (pure; already builds for wasm32)
   terminal-core       NO CHANGE   (pure)
   terminal-input      NO CHANGE   (a wire protocol)
   terminal-render-model NO CHANGE (cells, not pixels)
   terminal-debugger   NO CHANGE   (text)

   terminal-pty        REWRITE the backend behind the SAME public API
   terminal-gui        SMALL CHANGES (winit already supports Windows; fonts and
                                      clipboard differ)
   terminal-mux        MEDIUM       (Unix sockets → named pipes; no daemon
                                      model; no SIGHUP semantics to exploit)
#![allow(unused)]
fn main() {
// The shape the abstraction must take.
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;

#[cfg(unix)]
pub use unix::Pty;
#[cfg(windows)]
pub use windows::Pty;

/// The API both must satisfy. Note what it does NOT expose: no raw fd, because
/// Windows has two handles and no fd. Exposing `master_fd()` in the public API
/// would have made a Windows backend impossible without a breaking change —
/// which is exactly the kind of leak a boundary audit catches.
pub trait PtyBackend {
    fn spawn(cfg: &PtyConfig) -> io::Result<Self> where Self: Sized;
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
    fn write(&mut self, buf: &[u8]) -> io::Result<usize>;
    fn resize(&self, size: PtySize) -> io::Result<()>;
    fn try_wait(&mut self) -> io::Result<Option<ExitStatus>>;
    fn kill(&mut self) -> io::Result<()>;
}
}

The lesson to extract: the master_fd() accessor that felt harmless in Lab 2 is a portability leak. On Unix it is natural; on Windows it cannot exist. If your event loop is written against master_fd(), the port requires changing the event loop too — and that is how "one crate changes" becomes "three crates change."

The fix is to expose readiness rather than the fd: an AsFd on Unix behind a #[cfg], and a portable register(&self, registry) in the shared API.


portable-pty: How Someone Else Solved It

WezTerm's portable-pty crate is the reference implementation of exactly this abstraction.

#![allow(unused)]
fn main() {
use portable_pty::{native_pty_system, CommandBuilder, PtySize};

let pty_system = native_pty_system();           // picks Unix or ConPTY
let pair = pty_system.openpty(PtySize {
    rows: 24, cols: 80, pixel_width: 0, pixel_height: 0,
})?;
let child = pair.slave.spawn_command(CommandBuilder::new("bash"))?;
let mut reader = pair.master.try_clone_reader()?;
let mut writer = pair.master.take_writer()?;
}

Its API design choices are instructive:

ChoiceWhy
master/slave split as separate objectsWindows has no single "master"
try_clone_reader() returns a ReadBecause you cannot poll a Windows handle, reading happens on a thread
No fd accessor in the portable APIThe leak described above, avoided
PtySize includes pixel dimensionsBoth platforms support them
CommandBuilder rather than raw argvWindows command-line quoting is genuinely different

Read its source. It is a few thousand lines, it is the exact problem you have been solving, and seeing someone else's answer after forming your own is the most efficient learning there is.


   REQUIRED:   Linux or macOS. Everything in Sections 1-5.
   ENCOURAGED: Test on BOTH Linux and macOS. The differences are small and
               finding them teaches you where the real boundaries are.
   OPTIONAL:   A ConPTY backend, AFTER Milestone 13, as the ultimate boundary
               test. If it takes more than one crate, your boundaries were wrong
               and you have learned something valuable.
   NOT NEEDED: Windows GUI work. winit handles it, and it teaches you little.

Experiment

CLAIM. The Linux/macOS differences are few, real, and findable by running the same test suite on both.

METHOD.

# 1. Run the suite on both platforms and diff the results.
cargo test --workspace 2>&1 | tee /tmp/results-$(uname -s).txt
#    ...on the other platform...
diff /tmp/results-Linux.txt /tmp/results-Darwin.txt

# 2. Any difference in a terminal-core test is a BUG — that crate is pure and
#    must behave identically.
#    Any difference in terminal-pty is expected; document it.

# 3. The specific probe: what does read(master) do after the child exits?
python3 - <<'EOF'
import os, pty, time
pid, fd = pty.fork()
if pid == 0: os._exit(0)
time.sleep(0.3)
try:
    print("read returned", os.read(fd, 100))
except OSError as e:
    print("read raised", e)
EOF
#    Linux:  read raised [Errno 5] Input/output error
#    macOS:  read returned b''

# 4. Count the platform cfgs in your workspace.
grep -rn 'cfg(unix)\|cfg(windows)\|cfg(target_os' crates/ --include=*.rs | wc -l
grep -rn 'cfg(unix)\|cfg(windows)\|cfg(target_os' crates/ --include=*.rs \
  | grep -v terminal-pty | grep -v terminal-gui
#    The second command must print NOTHING.

PREDICTION. Before step 4: how many platform cfgs does your workspace have, and how many are outside terminal-pty and terminal-gui?

RESULT. Every cfg outside those two crates is a portability leak. Record where each one is and what it would take to move it. Most turn out to be behavior differences that should have been configuration.


Test

#![allow(unused)]
fn main() {
#[test]
fn eof_condition_is_handled_on_both_platforms() {
    // The read-after-exit difference, tested explicitly rather than discovered
    // in production.
    let mut pty = Pty::spawn(&cfg_for("/bin/true")).unwrap();
    std::thread::sleep(Duration::from_millis(300));
    let mut buf = [0u8; 1024];
    // Drain any remaining output, then expect a clean end on either platform.
    loop {
        match pty.read(&mut buf) {
            Ok(0) => break,                                              // macOS
            Err(e) if e.raw_os_error() == Some(libc::EIO) => break,      // Linux
            Ok(_) => continue,
            Err(e) if e.kind() == ErrorKind::Interrupted => continue,
            Err(e) => panic!("unexpected error: {e}"),
        }
    }
}

#[test]
fn ioctl_constants_come_from_libc_not_literals() {
    // Hardcoding 0x5413 works on Linux/x86_64 and corrupts memory elsewhere.
    let src = std::fs::read_to_string("crates/terminal-pty/src/lib.rs").unwrap();
    assert!(!src.contains("0x5413") && !src.contains("0x5414"),
            "use libc::TIOCGWINSZ / libc::TIOCSWINSZ, never literals");
}

#[test]
fn platform_cfgs_are_confined_to_pty_and_gui() {
    let mut leaks = Vec::new();
    for entry in walkdir("crates") {
        let p = entry.path();
        if p.extension().and_then(|e| e.to_str()) != Some("rs") { continue; }
        let s = p.to_string_lossy();
        if s.contains("terminal-pty") || s.contains("terminal-gui") { continue; }
        let src = std::fs::read_to_string(p).unwrap();
        if src.contains("cfg(unix)") || src.contains("cfg(windows)")
            || src.contains("cfg(target_os") {
            leaks.push(s.to_string());
        }
    }
    assert!(leaks.is_empty(), "portability leaks: {leaks:#?}");
}

#[test]
fn pty_public_api_does_not_expose_a_raw_fd() {
    // A master_fd() in the PORTABLE API makes a Windows backend impossible
    // without a breaking change. Unix-only accessors must be cfg-gated.
    let src = std::fs::read_to_string("crates/terminal-pty/src/lib.rs").unwrap();
    if src.contains("pub fn master_fd") {
        assert!(src.contains("#[cfg(unix)]"), "master_fd must be cfg(unix)-gated");
    }
}
}

Challenge Extensions

  1. Run the full suite on both Linux and macOS in CI. Any terminal-core difference is a bug; fix it. Any terminal-pty difference gets documented.
  2. Implement a ConPTY backend behind the same PtyBackend trait. Count how many crates you had to touch — that number is your boundary grade.
  3. Compare portable-pty's API with yours and write up every difference and why it exists.
  4. kqueue vs. epoll: implement the event loop both ways behind a trait and compare the code. Note that kqueue can watch signals and child exits natively, which removes the self-pipe.
  5. Measure the ConPTY overhead: conhost.exe sits in the middle. On a Windows machine, compare throughput of catting a large file through ConPTY against a Unix PTY on comparable hardware.
  6. A FreeBSD or OpenBSD port — closer to macOS than to Linux, and a cheap way to find remaining Linux assumptions.

Validation / Self-check

  1. Draw the three PTY models. What is the fundamental difference between Unix and ConPTY?
  2. Name the two Linux/macOS differences that reach your code, and how you handle each.
  3. Why must you never hardcode an ioctl request number?
  4. Map the Unix PTY lifecycle onto ConPTY: allocation, spawn, resize, teardown.
  5. Name five things ConPTY has no equivalent for.
  6. Why does ConPTY exist as a user-space process rather than a kernel object?
  7. Which crates would change in a Windows port, and which would not?
  8. Why is master_fd() a portability leak? What is the alternative?
  9. What does portable-pty's API design tell you about the constraints?
  10. Run the cfg-leak check on your workspace. What did you find, and was it behavior or platform?

Next: The Capstone.