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
| Aspect | Linux | macOS |
|---|---|---|
| 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_r | Yes (glibc) | Present on recent versions; ptsname is the portable choice |
read(master) after the child exits | EIO | returns 0 (EOF) |
openpty header | <pty.h>, link -lutil | <util.h>, in libSystem |
| Polling | epoll (also poll, select) | kqueue (also poll, select) |
| Signal-as-fd | signalfd | kqueue EVFILT_SIGNAL |
| Child exit as an event | pidfd_open (modern) | kqueue EVFILT_PROC |
| Process inspection | /proc/<pid>/{fd,stat,status,wchan} | lsof, sysctl, libproc |
| Syscall tracing | strace | dtruss (sudo; SIP-restricted for system binaries) |
stty on another tty | stty -F /dev/pts/N | stty -f /dev/ttysNNN |
Default TERM | Distribution-dependent | xterm-256color |
TIOCSCTTY arg type | c_ulong on Linux | differs; 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
| Unix | Windows ConPTY |
|---|---|
posix_openpt + grantpt + unlockpt + ptsname | CreatePseudoConsole |
| master fd | two pipe HANDLEs (read and write are separate) |
slave /dev/pts/N | the pseudoconsole handle, attached via a thread attribute |
fork + setsid + TIOCSCTTY + dup2 + execve | CreateProcessW with PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE |
ioctl(TIOCSWINSZ) | ResizePseudoConsole |
ioctl(TIOCGWINSZ) | GetConsoleScreenBufferInfo (child side) |
SIGWINCH | No signal. The console API notifies differently. |
SIGINT from ^C | GenerateConsoleCtrlEvent(CTRL_C_EVENT, ...) |
SIGHUP on master close | Closing the pseudoconsole terminates the attached processes |
termios | Console modes: SetConsoleMode with ENABLE_VIRTUAL_TERMINAL_INPUT etc. |
| The line discipline | ENABLE_LINE_INPUT / ENABLE_ECHO_INPUT in the console mode |
poll/epoll/kqueue | Overlapped I/O + IOCP, or a thread per handle |
Sessions, process groups, tcsetpgrp | No equivalent. Job objects are related but different. |
/dev/pts/N device path | No path. There is no filesystem object. |
The differences that matter architecturally
| Difference | Consequence |
|---|---|
| Two handles, not one fd | Your Pty type cannot be a single OwnedFd. Abstract over "a readable thing and a writable thing." |
No poll on handles | You need overlapped I/O with IOCP, or a reader thread per handle. mio and tokio handle this; raw code does not. |
| No signals | Job control has no analogue. ^C is GenerateConsoleCtrlEvent, and there is no SIGTSTP. |
conhost.exe is in the middle | A 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 VT | A legacy program using the Console API works because conhost translates. Its output is generated by conhost, not by the program. |
No /dev/pts/N | Nothing to stty, nothing to lsof, nothing to inspect. Debugging is materially harder. |
| Window size has no signal | Resize notification reaches the child through the console API, not SIGWINCH. |
| Different escape-sequence dialect | conhost 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:
| Choice | Why |
|---|---|
master/slave split as separate objects | Windows has no single "master" |
try_clone_reader() returns a Read | Because you cannot poll a Windows handle, reading happens on a thread |
| No fd accessor in the portable API | The leak described above, avoided |
PtySize includes pixel dimensions | Both platforms support them |
CommandBuilder rather than raw argv | Windows 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.
Recommended Scope
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
- Run the full suite on both Linux and macOS in CI. Any
terminal-coredifference is a bug; fix it. Anyterminal-ptydifference gets documented. - Implement a ConPTY backend behind the same
PtyBackendtrait. Count how many crates you had to touch — that number is your boundary grade. - Compare
portable-pty's API with yours and write up every difference and why it exists. kqueuevs.epoll: implement the event loop both ways behind a trait and compare the code. Note thatkqueuecan watch signals and child exits natively, which removes the self-pipe.- Measure the ConPTY overhead:
conhost.exesits in the middle. On a Windows machine, compare throughput ofcatting a large file through ConPTY against a Unix PTY on comparable hardware. - A FreeBSD or OpenBSD port — closer to macOS than to Linux, and a cheap way to find remaining Linux assumptions.
Validation / Self-check
- Draw the three PTY models. What is the fundamental difference between Unix and ConPTY?
- Name the two Linux/macOS differences that reach your code, and how you handle each.
- Why must you never hardcode an
ioctlrequest number? - Map the Unix PTY lifecycle onto ConPTY: allocation, spawn, resize, teardown.
- Name five things ConPTY has no equivalent for.
- Why does ConPTY exist as a user-space process rather than a kernel object?
- Which crates would change in a Windows port, and which would not?
- Why is
master_fd()a portability leak? What is the alternative? - What does
portable-pty's API design tell you about the constraints? - Run the cfg-leak check on your workspace. What did you find, and was it behavior or platform?
Next: The Capstone.