Lab 15: Multiple Terminal Sessions (Milestone 9)

Background

One process, N PTYs, N Terminals, one event loop. No panes yet, no server yet — just the proof that your terminal core can host many independent sessions at once, and that hidden sessions keep working.

This is the smallest step that makes a multiplexer possible, and it is where you discover that "hidden" does not mean "paused."

Why This Lab Matters

  • It proves terminal-core is genuinely display-independent. Fifty terminals, no window.
  • The "why must I keep parsing invisible output?" question gets answered by a hang.

Prerequisites


Predict First

  1. You switch away from a pane running top for 60 seconds, then switch back. What do you see?
  2. You stop reading a hidden session's master. What happens to the child?
  3. You read but do not parse a hidden session, buffering the bytes instead. What is the memory cost after an hour of top?
  4. Three sessions, one 80×24 and two 100×30. Is that legal?

Step 1: The Session Manager

cargo new --lib crates/terminal-mux --name terminal-mux
[dependencies]
terminal-core = { path = "../terminal-core" }
terminal-pty  = { path = "../terminal-pty" }
libc = "0.2"
# NOT terminal-gui. Enforced in CI:
#   cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer'   → empty
#![allow(unused)]
fn main() {
pub type SessionId = u32;

pub struct Session {
    pub id: SessionId,
    pub name: String,
    pty: Pty,
    /// A HEADLESS terminal. No fonts, no window, no display connection.
    /// This is the payoff of terminal-core having no I/O dependencies.
    terminal: Terminal,
    /// Each session has its OWN size. They need not agree.
    size: PtySize,
    exited: Option<i32>,
}

pub struct SessionManager {
    sessions: BTreeMap<SessionId, Session>,
    active: Option<SessionId>,
    next_id: SessionId,
}

impl SessionManager {
    pub fn create(&mut self, name: &str, cmd: &[String], size: PtySize)
        -> io::Result<SessionId>
    {
        let pty = Pty::spawn(&PtyConfig::from(cmd, size))?;
        let id = self.next_id;
        self.next_id += 1;
        self.sessions.insert(id, Session {
            id, name: name.to_string(), pty,
            terminal: Terminal::new(size.rows as usize, size.cols as usize),
            size, exited: None,
        });
        if self.active.is_none() { self.active = Some(id); }
        Ok(id)
    }

    pub fn close(&mut self, id: SessionId) {
        if let Some(mut s) = self.sessions.remove(&id) {
            s.pty.kill();          // SIGHUP then SIGKILL after a grace period
            let _ = s.pty.wait();  // reap, or you accumulate zombies
        }
        // Closing the ACTIVE session must pick a new one, or input goes nowhere
        // and the user sees a frozen terminal.
        if self.active == Some(id) {
            self.active = self.sessions.keys().next().copied();
        }
    }
}
}

Step 2: One Event Loop for All Sessions

#![allow(unused)]
fn main() {
pub fn run(&mut self) -> io::Result<()> {
    let sig = SignalPipe::install(&[libc::SIGWINCH, libc::SIGCHLD, libc::SIGTERM])?;

    loop {
        // Build the poll set fresh each iteration: sessions come and go.
        let mut fds = vec![
            pollfd(0, libc::POLLIN),                    // stdin
            pollfd(sig.read_fd, libc::POLLIN),
        ];
        let ids: Vec<SessionId> = self.sessions.keys().copied().collect();
        for id in &ids {
            let s = &self.sessions[id];
            let mut ev = libc::POLLIN;
            if s.pending_output() { ev |= libc::POLLOUT; }
            fds.push(pollfd(s.pty.master_fd(), ev));
        }

        let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) };
        if n < 0 { /* EINTR handling as in Lab 3 */ }

        // ── stdin → the ACTIVE session only ────────────────────────────
        if fds[0].revents & libc::POLLIN != 0 {
            let bytes = read_stdin()?;
            if let Some(rest) = self.handle_switch_keys(&bytes) {
                if let Some(active) = self.active {
                    self.sessions.get_mut(&active).unwrap().pty.write_all(rest)?;
                }
            }
        }

        // ── EVERY master is read, EVERY session is parsed ───────────────
        // Not just the active one. Skipping hidden sessions makes their
        // children block in write() once the PTY buffer fills (~4-64 KB), and
        // the user reports "my background job hung."
        for (i, id) in ids.iter().enumerate() {
            let pfd = &fds[2 + i];
            if pfd.revents & (libc::POLLIN | libc::POLLHUP) == 0 { continue; }
            let session = self.sessions.get_mut(id).unwrap();
            loop {
                match session.pty.read(&mut self.buf) {
                    Ok(0) => { session.exited = Some(0); break; }
                    Ok(n) => {
                        // Parse into the session's OWN Terminal. Memory stays
                        // constant: one screen plus bounded scrollback, whether
                        // the session is visible or has been hidden for a week.
                        session.terminal.advance(&self.buf[..n]);
                        let replies = session.terminal.take_replies();
                        if !replies.is_empty() { session.pty.queue_write(&replies); }
                    }
                    Err(e) if e.kind() == ErrorKind::WouldBlock => break,
                    Err(e) if e.kind() == ErrorKind::Interrupted => continue,
                    Err(e) if e.raw_os_error() == Some(libc::EIO) => {
                        session.exited = Some(0); break;
                    }
                    Err(e) => return Err(e),
                }
            }
        }

        // ── Render ONLY the active session ──────────────────────────────
        if let Some(active) = self.active {
            let s = &mut self.sessions[&active];
            if s.terminal.damage().any() {
                self.render(&s.terminal)?;
                s.terminal.clear_damage();
            }
        }

        self.reap_exited();
    }
}
}

The three rules this loop encodes:

  1. Input goes only to the active session.
  2. Output is read and parsed from every session, always.
  3. Rendering happens only for the active session.

Step 3: Rendering to a Terminal (Not a Window)

The simplest client is a terminal client: your program runs in a terminal and redraws by emitting escape sequences. That is also what the mux client will do in Lab 17, so build it here.

#![allow(unused)]
fn main() {
/// Render a headless Terminal into the OUTER terminal by emitting escape
/// sequences. Full repaint for now; incremental is a challenge.
fn render(&mut self, term: &Terminal) -> io::Result<()> {
    let mut out = Vec::with_capacity(8192);
    out.extend_from_slice(b"\x1b[H");            // home; do NOT clear (it flickers)
    for row in 0..term.rows() {
        out.extend_from_slice(format!("\x1b[{};1H", row + 1).as_bytes());
        out.extend_from_slice(b"\x1b[K");         // erase to end of line
        for run in term.row_runs(row) {
            out.extend_from_slice(&sgr_for(&run.style));
            out.extend_from_slice(run.text.as_bytes());
        }
        out.extend_from_slice(b"\x1b[0m");
    }
    // Position the outer cursor where the inner terminal's cursor is, and
    // respect the inner terminal's cursor visibility.
    let c = term.cursor();
    out.extend_from_slice(format!("\x1b[{};{}H", c.row + 1, c.col + 1).as_bytes());
    out.extend_from_slice(if term.modes().contains(Mode::CURSOR_VISIBLE)
                          { b"\x1b[?25h" } else { b"\x1b[?25l" });
    self.stdout.write_all(&out)?;
    self.stdout.flush()
}
}

Tip: Do not send \x1b[2J on every frame. Clearing and redrawing flickers visibly. Position, erase-to-end-of-line, and rewrite each row instead.


Step 4: Session Switching

#![allow(unused)]
fn main() {
/// Intercept switch keys before forwarding. This is the prefix mechanism in
/// miniature; Lab 17 generalizes it.
fn handle_switch_keys<'a>(&mut self, bytes: &'a [u8]) -> Option<&'a [u8]> {
    match self.prefix_state {
        PrefixState::Normal => {
            if let Some(pos) = bytes.iter().position(|&b| b == PREFIX_BYTE) {
                // Forward everything BEFORE the prefix, then enter prefix state.
                let (before, _) = bytes.split_at(pos);
                self.prefix_state = PrefixState::Waiting;
                if !before.is_empty() { self.forward(before); }
                self.pending = bytes[pos + 1..].to_vec();
                return None;
            }
            Some(bytes)
        }
        PrefixState::Waiting => {
            self.prefix_state = PrefixState::Normal;
            match bytes.first() {
                Some(b'n') => { self.next_session(); None }
                Some(b'p') => { self.prev_session(); None }
                Some(b'c') => { let _ = self.create("shell", &default_cmd(), self.size); None }
                Some(b'x') => { if let Some(a) = self.active { self.close(a); } None }
                Some(&b) if b == PREFIX_BYTE => Some(&bytes[..1]),   // literal escape
                _ => None,                                            // unknown: discard
            }
        }
    }
}

fn switch_to(&mut self, id: SessionId) {
    self.active = Some(id);
    // A full repaint from STATE, not a replay. The session may have scrolled a
    // thousand times while hidden; the state is the answer.
    if let Some(s) = self.sessions.get_mut(&id) { s.terminal.damage_all(); }
}
}

Expected Output

$ cargo run -p terminal-mux --bin mux-single
[session 0: shell]  ← status line
$ echo hello
hello
$ <prefix> c              # new session
[session 1: shell]
$ top                      # let it run

$ <prefix> p               # back to session 0
[session 0: shell]
$ sleep 60                 # wait a minute

$ <prefix> n               # back to session 1
[session 1: shell]
#   top shows CURRENT data — not a 60-second-old frame, and not a replay.

Debugging Steps

A hidden session's program hangs

You are not reading its master. Every session, every iteration, unconditionally.

Memory grows while detached

You are buffering raw bytes instead of parsing. Parse into the Terminal; memory becomes constant.

Switching shows a stale screen

You did not damage_all() on switch, so the renderer only drew rows that changed since the last render — which was before you switched away.

Switching flickers

You are sending \x1b[2J. Position and rewrite instead.

Input goes to the wrong session

You forwarded before updating active, or the prefix handler consumed the wrong slice.

Closing the active session freezes input

active still points at a removed session. Pick a new one on close.

Zombies accumulate

close kills but does not wait.


Experiment

CLAIM. A hidden session whose master is not read will block its child in write().

METHOD.

#![allow(unused)]
fn main() {
// Add a --dont-read-hidden flag that skips the read for non-active sessions.
}
cargo run -p terminal-mux --bin mux-single -- --dont-read-hidden
#   session 0: run `yes`
#   <prefix> c  to create session 1 and switch away
#   wait 5 seconds

From another terminal:

# Linux:
ps -o pid,stat,wchan,comm | grep yes
cat /proc/$(pgrep -n yes)/stack 2>/dev/null
# macOS:
sample $(pgrep -n yes) 1 -f /tmp/s.txt && grep -i write /tmp/s.txt

PREDICTION. Before running: how many bytes does yes write before blocking? How long does that take? What is its process state?

RESULT. Record the buffer size you inferred. Then switch back to session 0 and watch it resume — that resumption is the mechanism made visible.


Test

#![allow(unused)]
fn main() {
#[test]
fn hidden_sessions_keep_being_parsed() {
    let mut mgr = SessionManager::new();
    let a = mgr.create("a", &sh("for i in $(seq 1 5000); do echo $i; done; sleep 30"),
                       PtySize::new(24, 80)).unwrap();
    let b = mgr.create("b", &sh("sleep 30"), PtySize::new(24, 80)).unwrap();
    mgr.set_active(b);                       // hide `a`
    mgr.pump_for(Duration::from_secs(2));
    assert!(mgr.snapshot(a).contains("5000"),
            "a hidden session must be drained and parsed");
}

#[test]
fn sessions_may_have_different_sizes() {
    let mut mgr = SessionManager::new();
    let a = mgr.create("a", &sh("stty size"), PtySize::new(24, 80)).unwrap();
    let b = mgr.create("b", &sh("stty size"), PtySize::new(40, 120)).unwrap();
    mgr.pump_for(Duration::from_millis(800));
    assert!(mgr.snapshot(a).contains("24 80"));
    assert!(mgr.snapshot(b).contains("40 120"));
}

#[test]
fn input_reaches_only_the_active_session() {
    let mut mgr = SessionManager::new();
    let a = mgr.create("a", &sh("cat > /tmp/mux-test-a"), PtySize::new(24, 80)).unwrap();
    let b = mgr.create("b", &sh("cat > /tmp/mux-test-b"), PtySize::new(24, 80)).unwrap();
    mgr.set_active(a);
    mgr.write_input(b"to-a\n");
    mgr.set_active(b);
    mgr.write_input(b"to-b\n");
    mgr.pump_for(Duration::from_millis(500));
    mgr.close(a); mgr.close(b);
    assert_eq!(std::fs::read_to_string("/tmp/mux-test-a").unwrap().trim(), "to-a");
    assert_eq!(std::fs::read_to_string("/tmp/mux-test-b").unwrap().trim(), "to-b");
}

#[test]
fn memory_is_constant_regardless_of_hidden_output_volume() {
    // The parse-do-not-buffer property, measured.
    let mut mgr = SessionManager::new();
    let a = mgr.create("a", &sh("yes"), PtySize::new(24, 80)).unwrap();
    let b = mgr.create("b", &sh("sleep 60"), PtySize::new(24, 80)).unwrap();
    mgr.set_active(b);
    mgr.pump_for(Duration::from_secs(1));
    let m1 = mgr.approximate_memory();
    mgr.pump_for(Duration::from_secs(3));
    let m2 = mgr.approximate_memory();
    assert!(m2 < m1 * 2, "memory grew from {m1} to {m2}: are you buffering raw bytes?");
    let _ = a;
}

#[test]
fn closing_the_active_session_picks_a_new_one() {
    let mut mgr = SessionManager::new();
    let a = mgr.create("a", &sh("sleep 30"), PtySize::new(24, 80)).unwrap();
    let b = mgr.create("b", &sh("sleep 30"), PtySize::new(24, 80)).unwrap();
    mgr.set_active(a);
    mgr.close(a);
    assert_eq!(mgr.active(), Some(b));
}

#[test]
fn mux_has_no_gui_dependency() {
    // CI: cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer' → empty
}
}

Challenge Extensions

  1. Incremental rendering on switch: diff the previous rendered screen against the new one and emit only the differences. Measure the byte reduction.
  2. A status line listing sessions with the active one marked, plus each session's title from OSC 0/2.
  3. Activity indicators: mark a hidden session that has produced output since you left it.
  4. Bell propagation: a hidden session's BEL marks it in the status line.
  5. Fifty sessions. Create fifty, run top in each, and measure total memory and CPU. Report per-session cost — that number is the real answer to "can this scale."
  6. Session naming and a chooser UI.

Deliverables

  • SessionManager hosting N (Pty, Terminal) pairs.
  • One event loop reading all masters and parsing all sessions.
  • Input routed only to the active session.
  • Independent per-session sizes.
  • Session create, close, and switch, including closing the active one.
  • Terminal-based rendering without flicker.
  • The hidden-top test: switch away for 60 seconds and back to current data.
  • The blocking experiment, with prediction and result.
  • cargo tree -p terminal-mux shows no GUI crates.
  • The fifty-session memory measurement.

Validation / Self-check

  1. State the three input/output/render rules of the loop.
  2. Why must hidden sessions be read? What happens if they are not?
  3. Why parse rather than buffer? Give the memory comparison.
  4. Why is a full repaint from state correct when switching?
  5. Why must sessions be allowed different sizes?
  6. What happens if you close the active session without picking a new one?
  7. Why is \x1b[2J on every frame a bad idea?
  8. What did fifty sessions cost in memory? What dominates that number?
  9. Why does terminal-mux not depend on terminal-gui? What would you lose?

Next: Lab 16 — Panes and Layout.