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.

UnixWindowsConsequence
One master fdTwo 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 fdHandles are not pollableYou need overlapped I/O + IOCP, or a reader thread per handle
The PTY is a kernel objectConPTY is a user-space service (conhost.exe)A third process you did not spawn, with its own version skew and bugs
SIGWINCH, SIGINT, SIGHUPNo signals at allGenerateConsoleCtrlEvent for ^C; nothing for ^Z; no job control
Sessions, process groups, tcsetpgrpNo equivalentJob control has no analogue; Job Objects are related but different
termiosSetConsoleMode flagsSimilar in spirit, different in every detail
/dev/pts/N — a real pathNo filesystem objectNothing to stty, nothing to lsof. Debugging is materially harder.
The child emits VTThe child may use the Console API; conhost synthesizes VT for youOutput 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 WriteConsoleOutput and SetConsoleCursorPosition; 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

#GoalDemonstrable by
1The trait extracted; Unix still passescargo test on Linux/macOS, unchanged
2ConPTY spawns cmd.exe and echoesBytes flow both ways on Windows
3Resize worksmode con inside reports the size you set
4The event loop works on bothThe GUI runs on Windows
5The whole test suite passes on Windows CIGreen on three platforms
6The crate-count reportgit 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:

CrateLines changedExpected
terminal-protocol0
terminal-core0
terminal-input0, or a small amount for Windows key handling
terminal-render-model0
terminal-debugger0
terminal-ptyLarge — this is the point
terminal-guiSmall — winit already works; fonts and clipboard differ
terminal-muxMedium — 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:

MetricWhy
Throughput vs. Unix on comparable hardwareconhost sits in the middle; how much does it cost?
Sequences conhost emits that Unix programs never doRecording, diffed against a Unix recording of the same program
Startup latencyConPTY spawns a whole extra process

7. Known Traps

TrapSymptom
Not closing your copies of the child's pipe endsThe child never sees EOF; you hang forever
Attribute list freed before CreateProcessWUpdateProcThreadAttribute stores a pointer; a freed list is use-after-free
Wrong Drop orderDeadlock in ClosePseudoConsole
Trying to poll a handleNot possible. Reader thread, or IOCP.
Expecting SIGWINCHDoes not exist. ResizePseudoConsole, and the console API notifies the child.
Expecting job controlDoes not exist. Document the limitation.
Windows command-line quotingGenuinely its own project. Use CommandBuilder from portable-pty as a reference.
UTF-16 vs. UTF-8Win32 wants wide strings; convert carefully at the boundary
Assuming conhost behaves like a Unix line disciplineIt does not. ENABLE_LINE_INPUT/ENABLE_ECHO_INPUT are similar in spirit only.
Testing only on Windows 11ConPTY changed meaningfully across Windows versions

Deliverables

  • PtyBackend extracted; 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/pts inspection) and how your code degrades.
  • A comparison with portable-pty's API and why it made the choices it did.

Next: Project 5 — The Kitty Keyboard Protocol