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
| Request | Argument | Purpose |
|---|---|---|
TIOCGWINSZ | *struct winsize (out) | Get the window size |
TIOCSWINSZ | *struct winsize (in) | Set it, and send SIGWINCH to the foreground group |
TIOCSCTTY | int (0) | Make this terminal the controlling terminal of my session |
TIOCNOTTY | — | Give up the controlling terminal |
TIOCGPGRP / TIOCSPGRP | *pid_t | The foreground process group (what tcgetpgrp/tcsetpgrp call) |
TIOCSPTLCK | *int | Linux: unlock a PTY slave (what unlockpt calls) |
TIOCGPTN | *int | Linux: the PTY number (what ptsname uses) |
TIOCPKT | *int | Enable packet mode on the master: reads get a status byte prefix reporting flushes and termios changes. Used by rlogin/ssh-style relays. |
TIOCOUTQ / FIONREAD | *int | Bytes pending in the output/input queue |
TIOCSTI | *char | Push 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_ypixelare 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
| Actor | Action |
|---|---|
| Terminal emulator | Writes it (TIOCSWINSZ on the master) when its window resizes |
| Multiplexer | Writes it per pane, from its layout |
| Full-screen programs | Read it (TIOCGWINSZ) at startup and after every SIGWINCH |
| The shell | Reads it and exports LINES/COLUMNS (bash does this via checkwinsize) |
| Kernel | Stores 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::TIOCGWINSZhas different integer types across platforms (c_ulongon Linux,c_ulongon macOS but a differently-encoded value). Always use thelibcconstant; never hardcode0x5413. 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
| Mistake | Symptom |
|---|---|
Never calling TIOCSWINSZ | The 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 spawning | The child's startup query returns 0×0; some programs cache it and never recover |
| Setting it on the slave from the parent | Works (same object) but is confusing; convention is to set it on the master |
| Not setting it on resize | Programs render at the old size forever; wrapped lines land in the wrong columns |
Exporting stale LINES/COLUMNS into the child's environment | Programs 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 size | Comically 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
| Actor | Role |
|---|---|
| Kernel | Sends it |
| The terminal's foreground process group | Receives it |
| Full-screen programs | Handle it: re-query TIOCGWINSZ, re-lay out, redraw |
| Your emulator | Receives 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:
| Pattern | Portability | Mechanics |
|---|---|---|
| Self-pipe trick | Everywhere | Handler 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. |
signalfd | Linux only | Block the signal, create an fd that becomes readable when it is pending, poll it. No handler at all. |
kqueue EVFILT_SIGNAL | BSD/macOS only | Block 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:
- Ignore the
writeresult. If the pipe is full the loop is already behind and will re-read the state; retrying inside a handler risks a deadlock. - Send the signal number as the byte. One pipe can then carry
SIGWINCH,SIGCHLD,SIGTERM, andSIGINT, 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, aread()interrupted bySIGWINCHresumes instead of returningEINTR— convenient, but it means a blockingreadwill not wake up to let you process the resize. With apoll-based loop and a self-pipe this is fine, becausepollwakes on the pipe. WithoutSA_RESTARTyou must handleEINTRon every syscall. Pick one and be consistent; the event-loop design in Lab 3 usesSA_RESTARTplus 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
| Mistake | Symptom |
|---|---|
Not handling SIGWINCH at all | Inner programs never learn the new size |
Handling it but not re-querying TIOCGWINSZ | You propagate a stale size |
Doing the ioctl inside the handler | Usually "works"; occasionally deadlocks or corrupts state. Never do work in a handler. |
Using a Mutex/println! in the handler | Deadlock, reproducible only under load |
| Not draining the self-pipe | The loop spins at 100% CPU on a permanently-readable fd |
| Blocking self-pipe write | Handler blocks forever when the pipe fills → whole process wedges |
Forgetting that resize can arrive between your TIOCGWINSZ and TIOCSWINSZ | A 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:
SIGCHLDis 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:
| Path | Linux | macOS |
|---|---|---|
SIGCHLD → waitpid | Reliable | Reliable |
Master fd becomes readable, read() fails | EIO | Returns 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 onSIGCHLDloses it — a bug that shows up as "the last line of output is sometimes missing," which is maddening to diagnose. Read untilEIO/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
| Mistake | Symptom |
|---|---|
Blocking waitpid in the event loop | The terminal freezes while the child is alive |
Reaping only once per SIGCHLD | Zombies accumulate when children exit in bursts |
Exiting on SIGCHLD without draining the master | The last line of output is intermittently missing |
Treating EIO as a fatal error | An error message on every normal exit (Linux only) |
Not handling read() == 0 | Infinite loop on macOS after the child exits |
Leaving SIGCHLD blocked in the exec'd child | The shell's job control silently breaks |
Setting SIGCHLD to SIG_IGN | On 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
- What is
ioctlfor, and why canTIOCGWINSZnot be aread()? - Name the four fields of
struct winsize. Which two are usually zero, and who cares about them? - Who writes the window size, who reads it, and what connects them?
- Trace a window resize from the mouse drag to
vimredrawing, naming every syscall and signal. - Why must
SIGWINCHhandling be a two-hop process in your emulator? - What is the default disposition of
SIGWINCH, and what does that imply for old programs? - Name three async-signal-safe patterns for waking an event loop, and their platform availability.
- Why must the self-pipe's write end be non-blocking, and why do you ignore the
writeresult? - Why is
SIGCHLDnot queued, and what does that imply for how you reap? - What does
read()on a PTY master return after the child exits, on Linux and on macOS? - Why must you drain the master before exiting on
SIGCHLD? Describe the user-visible bug. - You unset
LINESandCOLUMNSbefore exec. Why?
Next: I/O Multiplexing.