Project 4: A Windows ConPTY Backend
2–3 weeks · ●●●●○ · touches the terminal-pty boundary, tested for real
The ultimate test of Section 5. If your boundaries are right, this touches one crate. If it touches three, you have learned something more valuable than the feature.
1. The Problem
Your terminal is Unix-only. Windows has a completely different model — ConPTY — and supporting it is the single hardest portability test a terminal codebase faces.
2. Why It Is Hard
Not because Win32 is unpleasant, but because ConPTY is architecturally different, not merely a different spelling.
| Unix | Windows | Consequence |
|---|---|---|
| One master fd | Two pipe HANDLEs (read and write, separate) | Pty cannot be an OwnedFd. Any API exposing master_fd() is now a breaking change. |
poll/epoll/kqueue on the fd | Handles are not pollable | You need overlapped I/O + IOCP, or a reader thread per handle |
| The PTY is a kernel object | ConPTY is a user-space service (conhost.exe) | A third process you did not spawn, with its own version skew and bugs |
SIGWINCH, SIGINT, SIGHUP | No signals at all | GenerateConsoleCtrlEvent for ^C; nothing for ^Z; no job control |
Sessions, process groups, tcsetpgrp | No equivalent | Job control has no analogue; Job Objects are related but different |
termios | SetConsoleMode flags | Similar in spirit, different in every detail |
/dev/pts/N — a real path | No filesystem object | Nothing to stty, nothing to lsof. Debugging is materially harder. |
| The child emits VT | The child may use the Console API; conhost synthesizes VT for you | Output can differ from what any Unix program would emit |
Note: That last row is worth dwelling on. ConPTY exists for compatibility, not fidelity. A legacy Windows program calls
WriteConsoleOutputandSetConsoleCursorPosition; conhost translates those calls into VT sequences on your behalf. The sequences you receive were generated by conhost, not by the program — so their style, and sometimes their correctness, is conhost's.
3. The Design
The API that must not change
#![allow(unused)] fn main() { // crates/terminal-pty/src/lib.rs #[cfg(unix)] mod unix; #[cfg(windows)] mod windows; #[cfg(unix)] pub use unix::Pty; #[cfg(windows)] pub use windows::Pty; /// Both backends satisfy this. Note what is ABSENT: no raw fd, because Windows /// has two handles and no fd. If your Unix API exposed `master_fd()` publicly, /// adding Windows is a breaking change — and discovering that is the point of /// this project. pub trait PtyBackend: Sized { fn spawn(cfg: &PtyConfig) -> io::Result<Self>; 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<()>; /// Readiness, abstracted. On Unix this wraps the fd; on Windows, a thread /// and a channel. The EVENT LOOP must be written against this, not an fd. fn readiness(&self) -> Readiness; } }
The ConPTY lifecycle
#![allow(unused)] fn main() { // crates/terminal-pty/src/windows.rs use windows_sys::Win32::System::Console::*; use windows_sys::Win32::System::Threading::*; pub struct Pty { hpc: HPCON, /// OUR ends. The child's ends were consumed by CreatePseudoConsole. input_write: HANDLE, output_read: HANDLE, process: PROCESS_INFORMATION, /// Windows handles are not pollable, so a reader thread pumps output into /// a channel and the event loop selects on the channel instead. reader: JoinHandle<()>, rx: Receiver<Vec<u8>>, /// Kept alive for the lifetime of the process: UpdateProcThreadAttribute /// stores a POINTER, so this must outlive CreateProcessW. attr_list: ProcThreadAttributeList, } impl PtyBackend for Pty { fn spawn(cfg: &PtyConfig) -> io::Result<Self> { // 1. Two pipes. Note which end goes where — getting this backwards // produces a child that hangs on its first read. let (in_read, in_write) = create_pipe()?; // in_read → the child let (out_read, out_write) = create_pipe()?; // out_write ← the child // 2. Create the pseudoconsole, handing it the CHILD's ends. let size = COORD { X: cfg.size.cols as i16, Y: cfg.size.rows as i16 }; let mut hpc: HPCON = std::ptr::null_mut(); // SAFETY: both handles are valid; hpc is written on success. let hr = unsafe { CreatePseudoConsole(size, in_read, out_write, 0, &mut hpc) }; if hr != 0 { return Err(io::Error::from_raw_os_error(hr)); } // 3. The child's ends are now owned by the pseudoconsole. Close OUR // copies or the child never sees EOF — the exact analogue of // "close the slave in the parent" on Unix. unsafe { CloseHandle(in_read); CloseHandle(out_write); } // 4. Attach the pseudoconsole to the process via a thread attribute. // This attribute list must OUTLIVE CreateProcessW. let attr_list = ProcThreadAttributeList::with_pseudoconsole(hpc)?; let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32; si.lpAttributeList = attr_list.as_ptr(); let mut cmdline = build_command_line(cfg); // Windows quoting is its own project let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; // SAFETY: si and pi are correctly sized and initialized. let ok = unsafe { CreateProcessW(std::ptr::null(), cmdline.as_mut_ptr(), std::ptr::null(), std::ptr::null(), 0, EXTENDED_STARTUPINFO_PRESENT, std::ptr::null(), std::ptr::null(), &mut si.StartupInfo, &mut pi) }; if ok == 0 { return Err(io::Error::last_os_error()); } // 5. Handles are not pollable → a reader thread. let (tx, rx) = std::sync::mpsc::channel(); let reader = spawn_reader_thread(out_read, tx); Ok(Pty { hpc, input_write: in_write, output_read: out_read, process: pi, reader, rx, attr_list }) } fn resize(&self, size: PtySize) -> io::Result<()> { let coord = COORD { X: size.cols as i16, Y: size.rows as i16 }; // No SIGWINCH: the console API notifies the child itself. let hr = unsafe { ResizePseudoConsole(self.hpc, coord) }; if hr != 0 { return Err(io::Error::from_raw_os_error(hr)); } Ok(()) } } impl Drop for Pty { fn drop(&mut self) { // ORDER MATTERS. ClosePseudoConsole flushes remaining output and waits // for conhost; closing our handles first can deadlock it. unsafe { ClosePseudoConsole(self.hpc); CloseHandle(self.input_write); CloseHandle(self.output_read); CloseHandle(self.process.hProcess); CloseHandle(self.process.hThread); } } } }
Ctrl+C
#![allow(unused)] fn main() { /// There is no SIGINT. The nearest equivalent is a console control event, and /// it applies to a process GROUP — which requires the child to have been /// created with CREATE_NEW_PROCESS_GROUP. fn send_interrupt(&self) -> io::Result<()> { // SAFETY: dwProcessId is valid for the lifetime of self. if unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, self.process.dwProcessId) } == 0 { return Err(io::Error::last_os_error()); } Ok(()) } }
There is no equivalent of SIGTSTP. ^Z on Windows means EOF in the console, not suspend. Job
control as Unix defines it does not exist. Document that rather than emulating it badly.
4. Milestones
| # | Goal | Demonstrable by |
|---|---|---|
| 1 | The trait extracted; Unix still passes | cargo test on Linux/macOS, unchanged |
| 2 | ConPTY spawns cmd.exe and echoes | Bytes flow both ways on Windows |
| 3 | Resize works | mode con inside reports the size you set |
| 4 | The event loop works on both | The GUI runs on Windows |
| 5 | The whole test suite passes on Windows CI | Green on three platforms |
| 6 | The crate-count report | git diff --stat per crate — the actual deliverable |
Milestone 1 is doable and valuable even if you never touch Windows. Extracting the trait and confirming Unix still passes proves the abstraction is right, and it is an afternoon.
5. The Tests
#![allow(unused)] fn main() { #[cfg(windows)] #[test] fn conpty_spawns_and_echoes() { let mut pty = Pty::spawn(&PtyConfig { program: "cmd.exe".into(), args: vec!["/c".into(), "echo hello".into()], size: PtySize::new(24, 80), ..Default::default() }).unwrap(); let out = read_until_eof(&mut pty, Duration::from_secs(5)); assert!(String::from_utf8_lossy(&out).contains("hello")); } #[cfg(windows)] #[test] fn conpty_reports_the_size_we_set() { let mut pty = Pty::spawn(&cmd("mode con", PtySize::new(30, 100))).unwrap(); let out = read_until_eof(&mut pty, Duration::from_secs(5)); let s = String::from_utf8_lossy(&out); assert!(s.contains("30") && s.contains("100"), "{s}"); } #[test] fn the_pty_api_exposes_no_raw_fd() { // The portability guard. A master_fd() in the PORTABLE API makes Windows // impossible without a breaking change. let src = include_str!("../src/lib.rs"); if src.contains("pub fn master_fd") { assert!(src.contains("#[cfg(unix)]"), "master_fd must be cfg(unix)-gated"); } } #[test] fn only_terminal_pty_and_gui_contain_platform_cfgs() { // The measurement this project exists to take. let leaks = find_platform_cfgs_outside(&["terminal-pty", "terminal-gui"]); assert!(leaks.is_empty(), "portability leaks: {leaks:#?}"); } #[test] fn core_tests_pass_identically_on_every_platform() { // terminal-core is pure. Any platform difference in it is a BUG, and CI on // three platforms is how you find out. } }
6. The Measurement
The deliverable is a table, not a benchmark:
| Crate | Lines changed | Expected |
|---|---|---|
terminal-protocol | 0 | |
terminal-core | 0 | |
terminal-input | 0, or a small amount for Windows key handling | |
terminal-render-model | 0 | |
terminal-debugger | 0 | |
terminal-pty | Large — this is the point | |
terminal-gui | Small — winit already works; fonts and clipboard differ | |
terminal-mux | Medium — Unix sockets → named pipes; no daemon model |
That table is your boundary grade. If terminal-core is not zero, find out why and write it up —
it is the most instructive paragraph you will produce in this whole curriculum.
Also worth measuring:
| Metric | Why |
|---|---|
| Throughput vs. Unix on comparable hardware | conhost sits in the middle; how much does it cost? |
| Sequences conhost emits that Unix programs never do | Recording, diffed against a Unix recording of the same program |
| Startup latency | ConPTY spawns a whole extra process |
7. Known Traps
| Trap | Symptom |
|---|---|
| Not closing your copies of the child's pipe ends | The child never sees EOF; you hang forever |
Attribute list freed before CreateProcessW | UpdateProcThreadAttribute stores a pointer; a freed list is use-after-free |
Wrong Drop order | Deadlock in ClosePseudoConsole |
Trying to poll a handle | Not possible. Reader thread, or IOCP. |
Expecting SIGWINCH | Does not exist. ResizePseudoConsole, and the console API notifies the child. |
| Expecting job control | Does not exist. Document the limitation. |
| Windows command-line quoting | Genuinely its own project. Use CommandBuilder from portable-pty as a reference. |
| UTF-16 vs. UTF-8 | Win32 wants wide strings; convert carefully at the boundary |
Assuming conhost behaves like a Unix line discipline | It does not. ENABLE_LINE_INPUT/ENABLE_ECHO_INPUT are similar in spirit only. |
| Testing only on Windows 11 | ConPTY changed meaningfully across Windows versions |
Deliverables
-
PtyBackendextracted; Unix behavior unchanged and tests still green. - A ConPTY backend behind the same API.
-
The event loop written against
Readiness, not against an fd. - CI green on Linux, macOS, and Windows.
- The lines-changed-per-crate table, with an explanation of any non-zero core rows.
-
A written list of what Windows cannot do (job control,
SIGTSTP,/dev/ptsinspection) and how your code degrades. -
A comparison with
portable-pty's API and why it made the choices it did.