Lab 3: The PTY Event Loop (Milestone 3)

Background

Lab 2 produced a shell that works until you resize the window, press Ctrl+Z, or paste a large block of text. This lab replaces the naïve two-thread relay with one poll() loop that correctly handles input, output, resize, signals, child termination, partial writes, and the Linux/macOS EOF difference.

When you finish, your runner is a correct transparent terminal. It still understands nothing about escape sequences — that is Section 2 — but everything underneath is right.

Why This Lab Matters

  • The loop you write here is the same loop, with more fds, in the GUI and the mux server.
  • Async-signal-safe signal handling is a skill that transfers to every systems program you will ever write.
  • Every bug this lab fixes is a bug real terminal emulators have shipped.

Prerequisites


Predict First

  1. Your SIGWINCH handler calls ioctl directly. Name a scenario where that deadlocks.
  2. poll reports POLLIN | POLLHUP on the master. If you exit immediately, what does the user lose?
  3. You paste 100 KB. write(master) returns 4096. What happens to the other 96 KB in Lab 2's code?
  4. SIGCHLD arrives. Should you exit immediately? Why or why not?

The Target Loop

 ┌───────────────────────────── poll(fds, -1) ─────────────────────────────┐
 │                                                                          │
 │  fd 0  (stdin)          POLLIN  → read → append to master_out buffer     │
 │  master                 POLLIN  → read → append to stdout_out buffer     │
 │                         POLLHUP → still read! only EOF/EIO means done    │
 │  master (when pending)  POLLOUT → drain master_out                       │
 │  fd 1  (when pending)   POLLOUT → drain stdout_out                       │
 │  signal_pipe_r          POLLIN  → drain, dispatch by signal number:      │
 │                                     'W' SIGWINCH → propagate size        │
 │                                     'C' SIGCHLD  → reap (WNOHANG, loop)  │
 │                                     'T' SIGTERM/SIGINT → shut down       │
 │                                                                          │
 │  exit condition: child reaped AND master drained to EOF/EIO              │
 └──────────────────────────────────────────────────────────────────────────┘

Two design decisions worth stating explicitly:

  1. Write-interest is dynamic. POLLOUT is only requested when there is buffered data. Leaving it registered on an idle fd makes poll return instantly forever and burns a CPU core.
  2. Exit requires both conditions. Reaping the child is not enough — its final output may still be in the PTY buffer. Exiting on SIGCHLD alone is the "last line of output is sometimes missing" bug.

Step-by-Step Tasks

Step 1: The signal plumbing

Create crates/pty-runner/src/signals.rs.

#![allow(unused)]
fn main() {
use std::io;
use std::os::fd::RawFd;
use std::sync::atomic::{AtomicI32, Ordering};

/// The write end of the self-pipe, readable from the signal handler.
/// An AtomicI32 because a handler may not take a lock or allocate.
static PIPE_W: AtomicI32 = AtomicI32::new(-1);

/// The signal handler. EVERY line here must be async-signal-safe:
/// write() is; println!, malloc, and Mutex are not.
extern "C" fn handler(sig: libc::c_int) {
    let fd = PIPE_W.load(Ordering::Relaxed);
    if fd < 0 { return; }
    let byte = sig as u8;
    // SAFETY: write() is async-signal-safe and `fd` is a valid pipe write end.
    // The result is deliberately ignored: if the pipe is full the event loop is
    // already behind and will re-examine state; retrying here risks wedging the
    // handler, which runs with the interrupted thread's stack.
    unsafe {
        libc::write(fd, &byte as *const u8 as *const libc::c_void, 1);
    }
}

pub struct SignalPipe {
    pub read_fd: RawFd,
}

impl SignalPipe {
    /// Create the self-pipe and install handlers for the given signals.
    pub fn install(signals: &[libc::c_int]) -> io::Result<SignalPipe> {
        let mut fds = [0 as RawFd; 2];
        // SAFETY: pipe() writes two fds through the pointer.
        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
            return Err(io::Error::last_os_error());
        }
        let (r, w) = (fds[0], fds[1]);

        // BOTH ends non-blocking:
        //   write end — so a full pipe cannot block the handler forever
        //   read end  — so draining it in the loop cannot block
        set_nonblocking(r)?;
        set_nonblocking(w)?;
        // Do not leak the pipe into children.
        set_cloexec(r)?;
        set_cloexec(w)?;

        PIPE_W.store(w, Ordering::Relaxed);

        for &sig in signals {
            // SAFETY: constructing and installing a sigaction with a valid handler.
            unsafe {
                let mut sa: libc::sigaction = std::mem::zeroed();
                sa.sa_sigaction = handler as usize;
                libc::sigemptyset(&mut sa.sa_mask);
                // SA_RESTART: interrupted syscalls resume rather than returning EINTR.
                // Safe here because poll() is woken by the pipe, not by EINTR.
                sa.sa_flags = libc::SA_RESTART;
                if libc::sigaction(sig, &sa, std::ptr::null_mut()) != 0 {
                    return Err(io::Error::last_os_error());
                }
            }
        }
        Ok(SignalPipe { read_fd: r })
    }

    /// Drain every pending signal byte. Returns the signal numbers received.
    /// Draining fully matters: a self-pipe left readable makes poll() spin.
    pub fn drain(&self) -> Vec<libc::c_int> {
        let mut out = Vec::new();
        let mut buf = [0u8; 64];
        loop {
            // SAFETY: read into a local buffer from our own non-blocking pipe.
            let n = unsafe {
                libc::read(self.read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
            };
            if n <= 0 { break; }               // EAGAIN when empty
            out.extend(buf[..n as usize].iter().map(|&b| b as libc::c_int));
        }
        out
    }
}

fn set_nonblocking(fd: RawFd) -> io::Result<()> {
    // SAFETY: fcntl on a valid fd.
    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
    if flags < 0 { return Err(io::Error::last_os_error()); }
    if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}

fn set_cloexec(fd: RawFd) -> io::Result<()> {
    // SAFETY: fcntl on a valid fd.
    let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
    if flags < 0 { return Err(io::Error::last_os_error()); }
    if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}
}

The four things to notice:

  1. Both pipe ends non-blocking. The write end so the handler never blocks; the read end so draining never blocks.
  2. FD_CLOEXEC. Otherwise every child inherits the pipe, and the pipe never reports EOF.
  3. Signal number as the byte. One pipe, many signals, dispatch in the loop.
  4. All logic is in drain(), called from the loop. The handler does one write.

Step 2: Buffered, non-blocking I/O helpers

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

/// A byte sink with a pending buffer, so short writes never lose data.
pub struct OutBuf {
    fd: RawFd,
    pending: VecDeque<u8>,
}

impl OutBuf {
    pub fn new(fd: RawFd) -> Self { Self { fd, pending: VecDeque::new() } }

    pub fn queue(&mut self, data: &[u8]) { self.pending.extend(data); }

    /// True when we need POLLOUT interest. Registering write-interest with an
    /// empty buffer is the classic 100%-CPU event-loop bug.
    pub fn wants_write(&self) -> bool { !self.pending.is_empty() }

    /// Write as much as the kernel will take. Returns Ok(()) even on a partial
    /// write — the remainder stays queued for the next POLLOUT.
    pub fn flush_some(&mut self) -> io::Result<()> {
        while !self.pending.is_empty() {
            let (front, _) = self.pending.as_slices();
            // SAFETY: front is a valid initialized slice; fd is open.
            let n = unsafe {
                libc::write(self.fd, front.as_ptr() as *const libc::c_void, front.len())
            };
            if n > 0 {
                self.pending.drain(..n as usize);
                continue;
            }
            let e = io::Error::last_os_error();
            return match e.kind() {
                io::ErrorKind::Interrupted => continue,       // EINTR: retry
                io::ErrorKind::WouldBlock => Ok(()),          // EAGAIN: later
                _ => Err(e),
            };
        }
        Ok(())
    }
}
}

Note: VecDeque::as_slices may return the data in two pieces after wraparound; the while loop handles that by re-slicing each iteration. A simpler Vec<u8> with a start index also works and is easier to reason about — pick one and be able to explain the trade-off.

Step 3: Resize propagation

#![allow(unused)]
fn main() {
/// Read the outer terminal's size and push it into the PTY.
/// Two ioctls, in this order, and neither may run inside a signal handler.
fn propagate_size(stdin_fd: RawFd, master_fd: RawFd) -> io::Result<()> {
    let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
    // SAFETY: TIOCGWINSZ writes exactly a winsize through the pointer.
    // (A zeroed winsize IS valid, unlike termios, so mem::zeroed is fine here.)
    if unsafe { libc::ioctl(stdin_fd, libc::TIOCGWINSZ, &mut ws) } != 0 {
        return Err(io::Error::last_os_error());
    }
    // Setting the size sends SIGWINCH to the PTY's foreground process group,
    // which is how `vim` and `top` learn to re-lay-out.
    // SAFETY: TIOCSWINSZ reads a winsize through the pointer.
    if unsafe { libc::ioctl(master_fd, libc::TIOCSWINSZ, &ws) } != 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(())
}
}

Step 4: The loop

#![allow(unused)]
fn main() {
pub fn run(pty: &mut Pty) -> io::Result<i32> {
    let stdin_fd = 0;
    let stdout_fd = 1;
    let master_fd = pty.master_fd();

    set_nonblocking(master_fd)?;
    // NOTE: we do NOT set O_NONBLOCK on stdin. It is shared with the parent shell
    // via the open file description, and leaving it non-blocking after we exit
    // breaks the user's shell. Instead we only read stdin when poll says it is
    // readable, which is enough. (Setting it and restoring it on exit is the
    // alternative — try both and pick one deliberately.)

    let sig = SignalPipe::install(&[libc::SIGWINCH, libc::SIGCHLD,
                                    libc::SIGTERM, libc::SIGINT])?;

    let mut to_master = OutBuf::new(master_fd);
    let mut to_stdout = OutBuf::new(stdout_fd);

    let mut child_status: Option<i32> = None;
    let mut master_eof = false;
    let mut buf = [0u8; 65536];

    // Push the initial size once, in case it changed between spawn and now.
    let _ = propagate_size(stdin_fd, master_fd);

    loop {
        // Exit only when BOTH the child is reaped AND the master is drained.
        if child_status.is_some() && master_eof && !to_stdout.wants_write() {
            break;
        }

        let mut fds = vec![
            libc::pollfd { fd: sig.read_fd, events: libc::POLLIN, revents: 0 },
        ];
        if !master_eof {
            let mut ev = libc::POLLIN;
            if to_master.wants_write() { ev |= libc::POLLOUT; }
            fds.push(libc::pollfd { fd: master_fd, events: ev, revents: 0 });
        }
        // Stop reading stdin once the child is gone — there is nowhere to send it.
        if child_status.is_none() {
            fds.push(libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0 });
        }
        if to_stdout.wants_write() {
            fds.push(libc::pollfd { fd: stdout_fd, events: libc::POLLOUT, revents: 0 });
        }

        // SAFETY: fds is a valid array; poll writes revents.
        let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, -1) };
        if n < 0 {
            let e = io::Error::last_os_error();
            if e.kind() == io::ErrorKind::Interrupted { continue; }   // EINTR
            return Err(e);
        }

        for pfd in &fds {
            if pfd.revents == 0 { continue; }

            if pfd.fd == sig.read_fd {
                for s in sig.drain() {
                    match s {
                        libc::SIGWINCH => { let _ = propagate_size(stdin_fd, master_fd); }
                        libc::SIGCHLD => {
                            // SIGCHLD is NOT queued: two children exiting close
                            // together may produce ONE signal. Always loop.
                            while let Some(code) = pty.try_reap() {
                                child_status = Some(code);
                            }
                        }
                        libc::SIGTERM | libc::SIGINT => {
                            // Forward, do not die. The inner shell decides what
                            // to do; we are the wire.
                            pty.signal_child(s);
                        }
                        _ => {}
                    }
                }
            } else if pfd.fd == stdin_fd && pfd.revents & libc::POLLIN != 0 {
                // SAFETY: reading into a local buffer from a valid fd.
                let n = unsafe {
                    libc::read(stdin_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
                };
                if n > 0 {
                    to_master.queue(&buf[..n as usize]);
                    to_master.flush_some()?;      // opportunistic: usually completes
                } else if n == 0 {
                    // Our own stdin closed. Send EOF onward by closing... but we
                    // cannot close the master without killing the child, so just
                    // stop watching stdin.
                    child_status.get_or_insert(0);
                }
            } else if pfd.fd == master_fd {
                if pfd.revents & libc::POLLOUT != 0 {
                    to_master.flush_some()?;
                }
                // IMPORTANT: read even when POLLHUP is set. On a PTY master you
                // typically get POLLIN|POLLHUP with the child's final output still
                // buffered. Treating HUP as immediate EOF loses the last line.
                if pfd.revents & (libc::POLLIN | libc::POLLHUP) != 0 {
                    loop {
                        // SAFETY: reading into a local buffer from a valid fd.
                        let n = unsafe {
                            libc::read(master_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
                        };
                        if n > 0 {
                            to_stdout.queue(&buf[..n as usize]);
                            continue;             // drain fully
                        }
                        if n == 0 {
                            master_eof = true;    // macOS: EOF after the child exits
                            break;
                        }
                        let e = io::Error::last_os_error();
                        match e.raw_os_error() {
                            // Linux: EIO on the master means the last slave closed.
                            // It is the EOF condition, NOT an error.
                            Some(libc::EIO) => { master_eof = true; break; }
                            Some(libc::EINTR) => continue,
                            Some(libc::EAGAIN) => break,          // == EWOULDBLOCK
                            _ => return Err(e),
                        }
                    }
                    to_stdout.flush_some()?;
                }
            } else if pfd.fd == stdout_fd && pfd.revents & libc::POLLOUT != 0 {
                to_stdout.flush_some()?;
            }
        }
    }

    // Final drain, in case anything is still queued.
    while to_stdout.wants_write() { to_stdout.flush_some()?; }
    Ok(child_status.unwrap_or(0))
}
}

Step 5: Run and verify

cargo run -p pty-runner

Then, inside:

# Resize test — resize your outer window while each of these runs:
top             # must re-lay-out immediately
vim             # must redraw at the new size
watch -n0.5 'stty size'

# Job control test:
sleep 100
# ^Z  → "[1]+ Stopped"
jobs ; bg ; fg
# ^C

# Flood test — must not freeze your runner:
yes | head -c 50000000

# Paste test — paste a very large block of text; nothing may be lost.

# Exit code propagation:
exit 42
echo $?         # must print 42

Expected Output

$ cargo run -p pty-runner
bash-5.2$ stty size
24 80
# (resize the window to 30x100)
bash-5.2$ stty size
30 100                      ← SIGWINCH → TIOCGWINSZ → TIOCSWINSZ worked

bash-5.2$ sleep 100
^Z
[1]+  Stopped                 sleep 100
bash-5.2$ jobs
[1]+  Stopped                 sleep 100
bash-5.2$ bg
[1]+ sleep 100 &
bash-5.2$ fg
sleep 100
^C
bash-5.2$ exit 42
exit
$ echo $?
42

Debugging Steps

100% CPU when idle

Either you registered POLLOUT with an empty buffer, or you are not draining the signal pipe. Print fds and revents each iteration for one second and look for a permanently-ready fd.

Resize does nothing

Check in order: (a) is SIGWINCH actually arriving? (add a counter, print it in the loop, not in the handler); (b) does TIOCGWINSZ on fd 0 return sensible numbers? (c) does TIOCSWINSZ on the master return 0? (d) is the inner program in the foreground process group?

The last line of output is sometimes missing

You exited on SIGCHLD without draining, or you treated POLLHUP as immediate EOF. Both are in the comments above for a reason.

The runner hangs after exit

The parent still holds a slave fd (Lab 2 bug), so the master never reports EOF. Grep your code for every open of the slave path.

Large pastes lose characters

Short writes. OutBuf fixes it; check you are actually calling flush_some on POLLOUT and not just once at queue time.

poll returns EINTR constantly

Your handler lacks SA_RESTART, or you installed with signal() instead of sigaction(). Either add SA_RESTART or handle EINTR at every call site — but be consistent.

Ctrl+C kills the runner

Your outer terminal is not in raw mode (ISIG still set), or you are forwarding SIGINT as an exit rather than to the child.


Experiment

CLAIM. A terminal that stops reading its PTY master will block the child in write().

METHOD.

#![allow(unused)]
fn main() {
// Temporarily add a "stall" flag: when set, skip the master POLLIN branch.
// Trigger it from stdin with a magic key, then in the inner shell run `yes`.
}

Then, from a third window:

# Linux:
ps -o pid,stat,wchan,comm -p <inner yes pid>
cat /proc/<pid>/stack 2>/dev/null
# macOS:
sample <pid> 1 -f /tmp/s.txt && grep -i write /tmp/s.txt

PREDICTION. Before running: does yes (a) keep running and discard output, (b) block, (c) get a signal, or (d) exit? Roughly how many bytes will it write before stalling?

RESULT. Record it, and note the buffer size you inferred. This experiment is why Milestone 7 insists that reading and rendering be decoupled.


Test

#![allow(unused)]
fn main() {
// crates/pty-runner/tests/loop_behavior.rs

#[test]
fn out_buf_handles_partial_writes() {
    // A pipe with a small buffer forces short writes. The OutBuf must retain the
    // remainder rather than silently dropping it — the paste-corruption bug.
    let (r, w) = make_pipe_nonblocking();
    let mut out = OutBuf::new(w);
    let big = vec![b'x'; 1_000_000];
    out.queue(&big);
    out.flush_some().unwrap();
    assert!(out.wants_write(), "a 1MB write into a 64KB pipe must leave a remainder");

    // Drain the reader; the rest must then flush cleanly with no loss.
    let mut total = drain_all(r);
    while out.wants_write() {
        out.flush_some().unwrap();
        total += drain_all(r);
    }
    assert_eq!(total, big.len(), "no bytes may be lost across partial writes");
}

#[test]
fn signal_pipe_delivers_the_signal_number() {
    let sig = SignalPipe::install(&[libc::SIGUSR1]).unwrap();
    unsafe { libc::raise(libc::SIGUSR1) };
    // The handler ran synchronously on raise(); the byte is already in the pipe.
    let got = sig.drain();
    assert_eq!(got, vec![libc::SIGUSR1]);
}

#[test]
fn signal_pipe_drains_completely() {
    // A partially drained self-pipe leaves poll() permanently ready → 100% CPU.
    let sig = SignalPipe::install(&[libc::SIGUSR1]).unwrap();
    for _ in 0..10 { unsafe { libc::raise(libc::SIGUSR1) }; }
    let got = sig.drain();
    assert!(!got.is_empty());
    assert!(sig.drain().is_empty(), "drain() must leave the pipe empty");
}

#[test]
fn resize_reaches_the_child() {
    // The end-to-end proof of the two-ioctl chain.
    let mut pty = spawn_shell(PtySize::new(24, 80));
    pty.resize(PtySize::new(40, 120)).unwrap();
    pty.write_all(b"stty size\n").unwrap();
    let out = read_until_contains(&mut pty, "40 120", Duration::from_secs(2));
    assert!(out.contains("40 120"), "child never saw the new size: {out}");
}
}

Challenge Extensions

  1. Port to nix or rustix, and diff. Rewrite the whole loop using safe wrappers. Read the two versions side by side and, for every line that disappeared, name the unsafety the crate absorbed. This is the exercise, not a formality.

  2. Port the signal plumbing to signalfd (Linux) or kqueue's EVFILT_SIGNAL (macOS). No handler at all — the signal becomes just another readable fd. Note what got simpler and what became platform-specific.

  3. Port the loop to mio. One Poll, Tokens, Interest. Observe that the shape is identical and only the registration bookkeeping changed.

  4. Add a timeout and a heartbeat. Give poll a 100 ms timeout and print stats — bytes in/out per second, buffer high-water marks. You now have the beginnings of instrumentation, and you will want the numbers in Section 3.

  5. Handle SIGTSTP on the runner itself. When your runner is suspended with ^Z from a shell that owns it, you must restore the outer terminal's termios before stopping, and re-enter raw mode on SIGCONT. Almost no toy terminal gets this right; try it.

  6. Detect a wedged child. Track how long the master has been non-readable while to_master has pending data, and report it. This is the seed of real observability.


Implementation Requirements / Deliverables

  • A single poll loop handling stdin, master, signals, and both output buffers.
  • Signal handling is async-signal-safe: the handler contains exactly one write.
  • Resize propagation verified with top, vim, and stty size.
  • SIGCHLD → waitpid(WNOHANG) in a loop; the exit code is propagated as your own.
  • The Linux EIO / macOS EOF difference handled explicitly, with a comment naming both.
  • Partial writes handled; a 100 KB paste arrives intact.
  • yes does not freeze the runner.
  • No busy-spin: CPU is ~0% when idle (verify with top).
  • The stall experiment performed, with prediction and result.
  • At least challenge 1 (nix/rustix port and diff) completed.

Validation / Self-check

  1. Why must the exit condition be "child reaped and master drained", not either alone?
  2. Why must you keep reading a master fd that reports POLLHUP?
  3. Name three things a signal handler may not do, and say what each one risks.
  4. Why are both ends of the self-pipe non-blocking?
  5. Why is the signal number sent as the byte rather than using one pipe per signal?
  6. What is the exact bug caused by leaving POLLOUT interest registered on an idle fd?
  7. Why is SIGCHLD reaped in a while loop?
  8. Your read(master) returns -1 with EIO on Linux. Is that an error? What is the macOS equivalent?
  9. Why does this lab avoid setting O_NONBLOCK on fd 0, and what is the alternative?
  10. Trace a window resize from mouse drag to vim redrawing, naming all four ioctls/signals.
  11. You paste 100 KB and write(master) returns 4096. Describe exactly what your loop does next.

Next: Lab 4 — The Experiments, where the concepts become observations.