ioctl, Window Size, SIGWINCH, SIGCHLD, and the Child Lifecycle

Four concepts: ioctl as the terminal's out-of-band control channel, TIOCGWINSZ/TIOCSWINSZ and the window size, SIGWINCH as the resize notification, and SIGCHLD plus the child lifecycle. Together they are everything your event loop must handle besides moving bytes.


Concept 1: ioctl

1. What problem it solves

read() and write() move data. Terminals also need control: what size is the window, who is the foreground process group, which line discipline is installed, make this my controlling terminal. None of those are data, and inventing a syscall for each would be absurd. ioctl() is the escape hatch: a per-device command channel keyed by a magic number.

2. Where it exists

Kernel, dispatched by the device driver. The same request number can mean different things on different device types — which is why the errors are ENOTTY ("inappropriate ioctl for device") when you aim a terminal ioctl at a file.

3. Who owns or interacts with it

Anyone with an fd on the device. Terminal ioctls are used by shells, full-screen programs, terminal emulators, and multiplexers.

4. The terminal ioctls you will use

RequestArgumentPurpose
TIOCGWINSZ*struct winsize (out)Get the window size
TIOCSWINSZ*struct winsize (in)Set it, and send SIGWINCH to the foreground group
TIOCSCTTYint (0)Make this terminal the controlling terminal of my session
TIOCNOTTY—Give up the controlling terminal
TIOCGPGRP / TIOCSPGRP*pid_tThe foreground process group (what tcgetpgrp/tcsetpgrp call)
TIOCSPTLCK*intLinux: unlock a PTY slave (what unlockpt calls)
TIOCGPTN*intLinux: the PTY number (what ptsname uses)
TIOCPKT*intEnable packet mode on the master: reads get a status byte prefix reporting flushes and termios changes. Used by rlogin/ssh-style relays.
TIOCOUTQ / FIONREAD*intBytes pending in the output/input queue
TIOCSTI*charPush a byte into the input queue. A security hazard; disabled or root-only on modern kernels.

termios functions are thin wrappers: tcgetattr is ioctl(fd, TCGETS, ...) on Linux, and tcsetpgrp is ioctl(fd, TIOCSPGRP, ...). Knowing that makes strace output readable.

5. Experiment

# Linux: watch a program's ioctls. Note TCGETS/TCSETS and TIOCGWINSZ.
strace -f -e trace=ioctl -o /tmp/vim.trace vim /dev/null
# then :q, and:
grep -E 'TCGETS|TCSETS|TIOCGWINSZ|TIOCSWINSZ|TIOCSPGRP' /tmp/vim.trace | head -30

# Simplest possible demonstration:
strace -e trace=ioctl stty -a 2>&1 | head

macOS: strace does not exist; sudo dtruss -f -t ioctl <cmd> is the nearest equivalent, and SIP restricts it for system binaries. Trace your own binaries instead — which is precisely what Lab 4 has you do.

6. Failure mode

Passing the wrong-sized struct, or an ioctl for a different device class, gives EINVAL or ENOTTY. In Rust the risk is worse: the ioctl variadic signature means the compiler cannot help you. Use nix::ioctl_read!/ioctl_write_ptr! macros or rustix's typed wrappers as soon as you have written the raw version once.


Concept 2: Window Size

1. What problem it solves

A program that draws a full screen needs to know how big the screen is. There is no way to derive it from the byte stream (well — there is a hack, see the note below), so the kernel stores it as a property of the terminal, and programs query it.

2. Where it exists

Kernel, one struct winsize per terminal (per PTY pair):

struct winsize {
    unsigned short ws_row;     // rows, in characters
    unsigned short ws_col;     // columns, in characters
    unsigned short ws_xpixel;  // width in pixels  — often 0
    unsigned short ws_ypixel;  // height in pixels — often 0
};

The kernel never reads these values. It stores them, hands them out on TIOCGWINSZ, and sends SIGWINCH when they change. It is a mailbox with a doorbell.

Note: ws_xpixel/ws_ypixel are not decoration. Terminal graphics protocols (sixel, the kitty graphics protocol, and image previewers) use them to compute pixel geometry. Most emulators leave them 0; a good one sets them. Set them in your emulator — it costs nothing and it is the kind of detail that separates a toy from a tool.

3. Who owns or interacts with it

ActorAction
Terminal emulatorWrites it (TIOCSWINSZ on the master) when its window resizes
MultiplexerWrites it per pane, from its layout
Full-screen programsRead it (TIOCGWINSZ) at startup and after every SIGWINCH
The shellReads it and exports LINES/COLUMNS (bash does this via checkwinsize)
KernelStores it; sends SIGWINCH

4. Syscalls and signals

ioctl(fd, TIOCGWINSZ, &ws)     read
ioctl(fd, TIOCSWINSZ, &ws)     write  → kernel sends SIGWINCH to the terminal's foreground pgroup

In Rust:

#![allow(unused)]
fn main() {
/// Read the window size of a terminal fd.
fn get_winsize(fd: i32) -> std::io::Result<libc::winsize> {
    let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
    // SAFETY: TIOCGWINSZ writes exactly a `struct winsize` through the pointer.
    // A zeroed winsize is a valid value (unlike termios), so mem::zeroed is fine here.
    if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) } != 0 {
        return Err(std::io::Error::last_os_error());
    }
    Ok(ws)
}

/// Set the window size on a PTY master. The kernel will SIGWINCH the foreground group.
fn set_winsize(master: i32, rows: u16, cols: u16, xpix: u16, ypix: u16) -> std::io::Result<()> {
    let ws = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: xpix, ws_ypixel: ypix };
    if unsafe { libc::ioctl(master, libc::TIOCSWINSZ, &ws) } != 0 {
        return Err(std::io::Error::last_os_error());
    }
    Ok(())
}
}

Warning: libc::TIOCGWINSZ has different integer types across platforms (c_ulong on Linux, c_ulong on macOS but a differently-encoded value). Always use the libc constant; never hardcode 0x5413. If you hardcode it you will get a program that works on Linux/x86 and silently corrupts memory on aarch64 macOS.

5. Experiment

# What does your terminal report?
stty size                # "24 80"
tput lines; tput cols

# Watch a program react. In terminal A:
watch -n0.2 'stty size'  # or:  while :; do stty size; sleep 0.2; done
# ...resize the window. The numbers change.

# Prove the kernel stores it per-PTY, not globally:
tty                      # /dev/pts/5
# In terminal B:
stty size -F /dev/pts/5  # Linux: reports A's size, not B's

Now the important one — set the size from outside:

# Terminal B, with A's tty:
# (Linux) forcibly resize A's terminal from another process:
python3 - <<'EOF'
import fcntl, struct, termios, sys
fd = open('/dev/pts/5', 'wb')          # ← A's tty
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 10, 40, 0, 0))
EOF
# In terminal A, a running `top`/`vim` immediately re-lays out at 10x40.

Predict first: does the emulator's window change size? Does stty size in A now report 10 40? What happens the next time you resize A's window with the mouse?

6. Failure mode

MistakeSymptom
Never calling TIOCSWINSZThe PTY reports 0×0. top and htop show nothing or crash. vim says "screen too small". Line wrapping is wrong everywhere.
Setting the size after spawningThe child's startup query returns 0×0; some programs cache it and never recover
Setting it on the slave from the parentWorks (same object) but is confusing; convention is to set it on the master
Not setting it on resizePrograms render at the old size forever; wrapped lines land in the wrong columns
Exporting stale LINES/COLUMNS into the child's environmentPrograms trust the env over the ioctl and render wrong. Unset both before exec.
Setting ws_row/ws_col from pixel dimensions without dividing by cell sizeComically wrong sizes

Concept 3: SIGWINCH

1. What problem it solves

A program that has already read the size needs to know when it changed. Polling would be wasteful. SIGWINCH ("window change") is the notification.

2. Where it exists

Kernel, generated on TIOCSWINSZ when the values actually change.

3. Who owns or interacts with it

ActorRole
KernelSends it
The terminal's foreground process groupReceives it
Full-screen programsHandle it: re-query TIOCGWINSZ, re-lay out, redraw
Your emulatorReceives SIGWINCH from its own outer terminal, and causes one in the inner PTY by calling TIOCSWINSZ

The two-hop chain in your program:

 user drags the window edge
        │
        ▼
 outer terminal emulator: ioctl(outer_master, TIOCSWINSZ, new)
        │
   ═════│═════ KERNEL ═════════════════════════════════════════
        ▼
   SIGWINCH → foreground pgroup of the outer tty  →  YOUR PROGRAM
   ═════│═════ USER SPACE ══════════════════════════════════════
        ▼
 your handler: write one byte to the self-pipe        ← async-signal-safe
        ▼
 your event loop:  ioctl(0, TIOCGWINSZ, &ws)          ← ask the outer terminal
                   ioctl(master, TIOCSWINSZ, &ws)     ← tell the inner PTY
        │
   ═════│═════ KERNEL ═════════════════════════════════════════
        ▼
   SIGWINCH → foreground pgroup of the INNER tty → vim
        ▼
 vim: ioctl(0, TIOCGWINSZ, &ws) → re-lay out → redraw

Note that SIGWINCH's default disposition is to be ignored, so a program that does not handle it simply does not notice. That is why a resize-unaware program keeps working (badly) rather than dying.

4. Signal-handling safety

A signal handler runs at an arbitrary point in your program, possibly in the middle of malloc. The only functions you may call are the async-signal-safe ones (write, _exit, signal, sigaction, and a short list). You may not allocate, take a lock, use println!, or touch a Mutex.

The three correct patterns:

PatternPortabilityMechanics
Self-pipe trickEverywhereHandler does write(pipe_w, &[b'W'], 1). The read end is registered in poll. Set both ends non-blocking so a full pipe cannot deadlock the handler.
signalfdLinux onlyBlock the signal, create an fd that becomes readable when it is pending, poll it. No handler at all.
kqueue EVFILT_SIGNALBSD/macOS onlyBlock the signal, register it with kqueue. No handler.

The self-pipe, in full:

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

static SIGNAL_PIPE_W: AtomicI32 = AtomicI32::new(-1);

extern "C" fn handle_signal(sig: libc::c_int) {
    let fd = SIGNAL_PIPE_W.load(Ordering::Relaxed);
    if fd < 0 { return; }
    let byte = sig as u8;
    // SAFETY: write() is async-signal-safe. We ignore the result: if the pipe is full,
    // the event loop already has pending work and will re-check state anyway.
    unsafe { libc::write(fd, &byte as *const u8 as *const libc::c_void, 1) };
}
}

Two subtleties worth naming:

  1. Ignore the write result. If the pipe is full the loop is already behind and will re-read the state; retrying inside a handler risks a deadlock.
  2. Send the signal number as the byte. One pipe can then carry SIGWINCH, SIGCHLD, SIGTERM, and SIGINT, and the loop dispatches on the value. Do not use a separate pipe per signal.

Register with sigaction, not signal:

#![allow(unused)]
fn main() {
unsafe fn install(sig: libc::c_int) {
    let mut sa: libc::sigaction = std::mem::zeroed();
    sa.sa_sigaction = handle_signal as usize;
    libc::sigemptyset(&mut sa.sa_mask);
    // SA_RESTART: restart interrupted syscalls instead of returning EINTR.
    // Deliberate choice — see the note below.
    sa.sa_flags = libc::SA_RESTART;
    libc::sigaction(sig, &sa, std::ptr::null_mut());
}
}

Note on SA_RESTART: With it, a read() interrupted by SIGWINCH resumes instead of returning EINTR — convenient, but it means a blocking read will not wake up to let you process the resize. With a poll-based loop and a self-pipe this is fine, because poll wakes on the pipe. Without SA_RESTART you must handle EINTR on every syscall. Pick one and be consistent; the event-loop design in Lab 3 uses SA_RESTART plus the pipe.

signal-hook implements exactly this and is the crate to use once you have written it by hand:

#![allow(unused)]
fn main() {
// signal_hook::iterator::Signals wraps the self-pipe;
// signal_hook::low_level::pipe::register writes to a pipe you own.
let mut signals = signal_hook::iterator::Signals::new([
    libc::SIGWINCH, libc::SIGCHLD, libc::SIGTERM,
])?;
}

5. Experiment

# Watch SIGWINCH arrive. Terminal A:
trap 'echo "SIGWINCH! new size: $(stty size)"' WINCH
# ...now resize the window. bash prints on every resize.
trap - WINCH

# Prove it goes to the FOREGROUND group only:
sleep 300 &                     # background
trap 'echo got winch' WINCH     # shell (foreground)
# resize: the shell reports; the background sleep is not in the fg group

Linux, watching the syscalls:

strace -e trace=ioctl,rt_sigaction -p $(pgrep -n vim)
# resize the window and watch: SIGWINCH → ioctl(0, TIOCGWINSZ, ...)

6. Failure mode

MistakeSymptom
Not handling SIGWINCH at allInner programs never learn the new size
Handling it but not re-querying TIOCGWINSZYou propagate a stale size
Doing the ioctl inside the handlerUsually "works"; occasionally deadlocks or corrupts state. Never do work in a handler.
Using a Mutex/println! in the handlerDeadlock, reproducible only under load
Not draining the self-pipeThe loop spins at 100% CPU on a permanently-readable fd
Blocking self-pipe writeHandler blocks forever when the pipe fills → whole process wedges
Forgetting that resize can arrive between your TIOCGWINSZ and TIOCSWINSZA missed resize. Harmless in practice; re-check after propagating if you care.

Concept 4: SIGCHLD and the Child Lifecycle

1. What problem it solves

Your emulator must know when the shell exits so it can shut down, report the exit code, and stop reading a dead PTY. Polling waitpid in a loop wastes CPU; blocking waitpid freezes the terminal. SIGCHLD is the notification.

2. Where it exists

Kernel, sent to the parent when a child terminates, stops, or continues.

3. Who owns or interacts with it

The parent (your emulator). Nobody else. Note that shells also use SIGCHLD for job control — which is why you must not leave SIGCHLD blocked when you exec a shell.

4. Syscalls and the state machine

   child running
        │
        ├── exits normally  ──▶ ZOMBIE  ──waitpid()──▶ reaped, gone
        │                        (exit status retained by the kernel)
        ├── killed by signal ─▶ ZOMBIE  ──waitpid()──▶ reaped, WIFSIGNALED
        └── stopped (^Z)     ─▶ STOPPED ──waitpid(WUNTRACED)──▶ reported, still alive
#![allow(unused)]
fn main() {
/// Reap without blocking. Call from the event loop after SIGCHLD, never from the handler.
fn try_reap(pid: libc::pid_t) -> Option<i32> {
    let mut status: libc::c_int = 0;
    // SAFETY: waitpid writes an int through the pointer.
    let r = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
    match r {
        0 => None,                        // still running
        -1 => None,                       // ECHILD: already reaped, or not our child
        _ => {
            if libc::WIFEXITED(status) {
                Some(libc::WEXITSTATUS(status))
            } else if libc::WIFSIGNALED(status) {
                Some(128 + libc::WTERMSIG(status))   // shell convention
            } else {
                None                      // stopped/continued — not an exit
            }
        }
    }
}
}

Warning: SIGCHLD is not queued. If two children exit in quick succession you may receive one signal. Always reap in a loop (while waitpid(-1, ..., WNOHANG) > 0) rather than reaping once per signal. With a single child this is academic; the moment you have panes it is not.

The two ways a child's death reaches you

You have two independent signals that the child is gone, and a correct program handles both:

PathLinuxmacOS
SIGCHLD → waitpidReliableReliable
Master fd becomes readable, read() failsEIOReturns 0 (EOF)

They race. Depending on timing you may see the fd condition first, or the signal first. Design for either order:

#![allow(unused)]
fn main() {
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() == io::ErrorKind::WouldBlock => {}  // nothing right now
    Err(e) if e.kind() == io::ErrorKind::Interrupted => {} // EINTR: retry
    Ok(n) => process(&buf[..n]),
    Err(e) => return Err(e),
}
}

Note: Drain the master before exiting on SIGCHLD. The child may have written its last output microseconds before dying, and that output is still sitting in the PTY buffer. Exiting immediately on SIGCHLD loses it — a bug that shows up as "the last line of output is sometimes missing," which is maddening to diagnose. Read until EIO/EOF, then exit.

5. Experiment

# Zombies: a child that is dead but not reaped.
bash -c 'sleep 1 & sleep 5' &
sleep 2 && ps -o pid,ppid,stat,comm | grep -E 'Z|defunct'   # STAT 'Z'

# The Linux EIO behavior, directly:
python3 - <<'EOF'
import os, pty, time
pid, fd = pty.fork()
if pid == 0:
    os._exit(0)                # child exits immediately
time.sleep(0.2)
try:
    print("read →", os.read(fd, 100))
except OSError as e:
    print("read raised", e)    # Linux: [Errno 5] Input/output error
EOF

Run that on both Linux and macOS if you can. Predict first what each prints.

6. Failure mode

MistakeSymptom
Blocking waitpid in the event loopThe terminal freezes while the child is alive
Reaping only once per SIGCHLDZombies accumulate when children exit in bursts
Exiting on SIGCHLD without draining the masterThe last line of output is intermittently missing
Treating EIO as a fatal errorAn error message on every normal exit (Linux only)
Not handling read() == 0Infinite loop on macOS after the child exits
Leaving SIGCHLD blocked in the exec'd childThe shell's job control silently breaks
Setting SIGCHLD to SIG_IGNOn Linux, children are auto-reaped and waitpid returns ECHILD — you never learn the exit code

Putting It All Together: the Event Loop's Non-Byte Work

 ┌──────────────────────── poll() ──────────────────────────┐
 │  fd 0 (stdin)        readable → read → write(master)     │
 │  master              readable → read → write(1)          │
 │                                 EIO/0 → drain, shutdown  │
 │  signal_pipe_r       readable → drain, dispatch:         │
 │                        b'W' (SIGWINCH):                  │
 │                            ioctl(0, TIOCGWINSZ)          │
 │                            ioctl(master, TIOCSWINSZ)     │
 │                        b'C' (SIGCHLD):                   │
 │                            while waitpid(WNOHANG) > 0    │
 │                            mark child_exited             │
 │                        b'T' (SIGTERM/SIGINT):            │
 │                            graceful shutdown             │
 └──────────────────────────────────────────────────────────┘
   shutdown: drain master until EIO/EOF → restore termios → exit(child_code)

That is Lab 3 in one diagram.


Validation / Self-check

  1. What is ioctl for, and why can TIOCGWINSZ not be a read()?
  2. Name the four fields of struct winsize. Which two are usually zero, and who cares about them?
  3. Who writes the window size, who reads it, and what connects them?
  4. Trace a window resize from the mouse drag to vim redrawing, naming every syscall and signal.
  5. Why must SIGWINCH handling be a two-hop process in your emulator?
  6. What is the default disposition of SIGWINCH, and what does that imply for old programs?
  7. Name three async-signal-safe patterns for waking an event loop, and their platform availability.
  8. Why must the self-pipe's write end be non-blocking, and why do you ignore the write result?
  9. Why is SIGCHLD not queued, and what does that imply for how you reap?
  10. What does read() on a PTY master return after the child exits, on Linux and on macOS?
  11. Why must you drain the master before exiting on SIGCHLD? Describe the user-visible bug.
  12. You unset LINES and COLUMNS before exec. Why?

Next: I/O Multiplexing.