Lab 18: Detach and Attach (Milestone 12)
Background
Detach and attach are the features people install a multiplexer for. Mechanically they are almost trivial once Lab 17 is right — the client closes a socket, and later a different client opens one. The interesting work is in what the server sends on attach and what happens when clients disagree about size.
Why This Lab Matters
- "Repaint from state, not from replay" is the design decision that makes reattach correct, and this is where you prove it.
- The multi-client resize problem has no correct answer, only documented policies — which is a genuinely useful thing to have practised.
Prerequisites
- Lab 17 complete.
- Panes, Windows, and Layout read (the resize policies).
Predict First
- You detach while
vimis on the alternate screen, wait an hour, and reattach. What do you see? - A server replays the last 1 MB of raw pane bytes on attach. Why is that wrong?
- Two clients, 200×50 and 80×24, attach to one session. What size are the panes?
- The last client detaches. What size should the session become?
Step 1: Detach
#![allow(unused)] fn main() { impl Client { fn detach(&mut self) -> io::Result<()> { // Tell the server, so it can clean up promptly rather than waiting for // the socket to report EOF. A polite detach; an impolite one (kill -9) // must work identically from the server's point of view. let _ = self.send(Request::Detach); let _ = self.flush_pending(); // Restore OUR terminal. Every mode we set, and the alternate screen. self.write_terminal( b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\ \x1b[?1004l\x1b[?25h\x1b[0m\x1b[?1049l")?; // RawMode's Drop restores termios. Ok(()) } } }
#![allow(unused)] fn main() { impl Server { fn on_client_gone(&mut self, id: ClientId) { let Some(client) = self.clients.remove(&id) else { return }; if let Some(session_id) = client.attached { let remaining = self.clients_attached_to(session_id).count(); if remaining == 0 { // DO NOT resize the session to zero, and DO NOT stop reading. // A 0x0 pane makes `top` divide by zero and `vim` refuse to // draw, and the damage persists after reattach. // The session keeps its last known size and keeps being parsed. } else { // With smallest-wins, losing a small client may GROW the session. self.recompute_session_size(session_id); } } } } }
Step 2: Attach — Repaint From State
#![allow(unused)] fn main() { impl Server { fn on_attach(&mut self, client_id: ClientId, session: Option<String>) -> io::Result<()> { let session_id = match session { Some(name) => self.find_session(&name).ok_or(Error::NoSuchSession)?, // No name: the most recently used session, or create one. `tmux // attach` with nothing running should not be an error the user has // to think about. None => self.most_recent_session().unwrap_or_else(|| self.create_default()), }; self.clients.get_mut(&client_id).unwrap().attached = Some(session_id); self.recompute_session_size(session_id); // THE FULL REPAINT, FROM STATE. // // Not a replay of buffered bytes. The pane's Terminal already holds the // answer: a screen, a cursor, a set of modes, and bounded scrollback. // Sending that is O(screen), correct regardless of how long the client // was away, and immune to the alternate-screen problem described below. let window = self.active_window(session_id); self.send_to(client_id, Event::LayoutChanged { session: self.session_name(session_id), window: window.index, layout: window.layout.snapshot(), })?; for (pane_id, rect) in &window.pane_rects { let pane = &self.panes[pane_id]; self.send_to(client_id, Event::PaneFull { pane: *pane_id, size: Size { rows: rect.rows as u16, cols: rect.cols as u16 }, screen: pane.terminal.snapshot(), cursor: pane.terminal.cursor_info(), // The client needs these to encode input correctly: DECCKM for // arrows, bracketed paste for pastes, the mouse modes, and // cursor visibility for rendering. modes: pane.terminal.client_visible_modes(), })?; } self.send_to(client_id, Event::FocusChanged { pane: window.focused.unwrap() })?; Ok(()) } } }
Why replay is wrong, concretely
Timeline:
14:00 pane runs `vim` → CSI ?1049h (enter alt screen)
→ CSI ?1h (application cursor keys)
→ CSI 1;40r (scroll region)
→ ...draws...
14:05 user quits vim → CSI ?1049l (leave alt screen)
14:05 user runs `top` → CSI ?1049h again, and 5 hours of frames
19:00 user reattaches
REPLAY of the last 1 MB:
The log window starts somewhere inside `top`'s output. It does NOT contain
the ?1049h that put the terminal on the alternate screen, nor the scroll
region, nor the mode sets. The replayed screen is drawn against a DEFAULT
terminal and is garbage.
To replay CORRECTLY you would have to keep every byte since the session
started — unbounded memory, and O(hours) to reattach.
STATE has none of these problems. It is the same size after five hours as
after five seconds, and it is exactly right.
Replay reconstructs history; state reconstructs the present. A reattaching client wants the present.
Step 3: Multi-Client Resize
#![allow(unused)] fn main() { fn recompute_session_size(&mut self, session_id: SessionId) { let sizes: Vec<Size> = self.clients_attached_to(session_id) .map(|c| c.size).collect(); if sizes.is_empty() { return; } // keep the last known size let new = match self.resize_policy { // SMALLEST WINS. The property that matters: every attached client can // display the entire session. A bigger client sees unused space, which // is strictly better than a smaller client seeing corruption. ResizePolicy::SmallestWins => sizes.iter().fold( Size { rows: u16::MAX, cols: u16::MAX }, |a, b| Size { rows: a.rows.min(b.rows), cols: a.cols.min(b.cols) }), ResizePolicy::LatestWins => *sizes.last().unwrap(), }; let session = self.sessions.get_mut(&session_id).unwrap(); if session.size == new { return; } // no-op guard: avoid SIGWINCH storms session.size = new; for window in session.windows_mut() { window.area = Rect { row: 0, col: 0, rows: new.rows as usize, cols: new.cols as usize }; self.apply_layout(window.id); // resizes terminals, then PTYs } // Every attached client needs a full repaint after a resize. for c in self.client_ids_attached_to(session_id) { self.queue_full_repaint(c, session_id); } } }
Document the policy where users will see it:
MULTI-CLIENT RESIZE POLICY: smallest-wins.
When several clients are attached to one session, every pane is sized to the
smallest attached client. A larger client sees unused space at the right and
bottom.
Rationale: the alternative (latest-wins) means a smaller client receives
content wider than its terminal, which wraps incorrectly and corrupts the
display. Unused space is a cosmetic cost; corruption is a functional one.
Alternatives considered:
• per-window sizing (tmux's aggressive-resize): better, more bookkeeping
• per-client viewports: most flexible, requires scrolling within a pane
• refuse multiple clients: simplest, loses pair programming
When the last client detaches, the session KEEPS its size. Resizing to zero
would make programs inside crash or refuse to draw.
Step 4: Multiple Clients, Concurrently
#![allow(unused)] fn main() { fn broadcast_pane_update(&mut self, pane_id: PaneId) { let pane = &self.panes[&pane_id]; let dirty: Vec<usize> = pane.terminal.damage().iter_dirty().collect(); if dirty.is_empty() { return; } let rows: Vec<(usize, RowSnapshot)> = dirty.iter() .map(|&r| (r, pane.terminal.row_snapshot(r))).collect(); for client_id in self.clients_viewing(pane_id) { let client = self.clients.get_mut(&client_id).unwrap(); if client.needs_full_repaint { // This client fell behind and we dropped its queue. A fresh snapshot // is both smaller and more correct than a backlog of increments. self.queue_full_repaint_pane(client_id, pane_id); continue; } client.queue_event(Event::PaneRows { pane: pane_id, rows: rows.clone(), cursor: pane.terminal.cursor_info(), }); } self.panes.get_mut(&pane_id).unwrap().terminal.clear_damage(); } }
Concurrency rules:
| Rule | Reason |
|---|---|
| Every attached client sees the same session state | One source of truth |
| Any client can type; input is serialized by the server's event loop | No locking needed — one thread owns everything |
| Focus is session state, not per-client | Both users see the same focused pane. tmux does this; the alternative is confusing. |
| Scroll position may be per-client | Reading history should not drag the other user around |
| Copy mode is per-client | It is a client-side view mode |
Expected Output
Terminal A Terminal B
────────── ──────────
$ mini-mux attach
(200x50 window)
$ vim main.rs
...editing...
$ mini-mux attach
(80x24 window)
← A's panes SHRINK to 80x24
vim redraws at the new size
...both see the same vim...
typing here appears in A
<prefix> d (A detaches)
← B's panes GROW to 80x24?
No — B was already the smallest.
No resize occurs.
$ mini-mux attach
← back to 80x24 (B still attached)
$ <prefix> d (B detaches)
← A's panes grow to 200x50
And the persistence test:
$ mini-mux attach
$ top
<prefix> d
$ sleep 300 # go away for five minutes
$ mini-mux attach
→ top is showing CURRENT data, not a five-minute-old frame,
and the reattach was instant.
Debugging Steps
Reattach shows a blank screen
You are not sending PaneFull, or the client is not applying it.
Reattach shows garbage after the pane used vim
You are replaying bytes instead of sending state.
Reattach is slow after a long detach
Same cause: replay is O(elapsed time). State is O(screen).
The session shrinks to 0×0 when the last client leaves
You are recomputing the size with an empty client list. Guard it.
SIGWINCH storms when a second client attaches
Missing the no-op guard in recompute_session_size.
Two clients see different content
You are sending per-client incremental updates from a per-client damage set that is not being cleared consistently. Damage is per-pane; the drop-and-repaint flag is per-client.
A slow client makes everything freeze
Blocking writes to client sockets. Non-blocking with a bounded buffer.
Experiment
CLAIM. Reattach from state is instant and correct regardless of how long you were detached; replay is neither.
METHOD.
# 1. Baseline: state-based reattach.
mini-mux server
mini-mux attach
# run: top
# <prefix> d
sleep 300
time mini-mux attach
# → instant, current data
# 2. Now implement --replay-on-attach (buffer the last 1 MB of raw pane bytes
# and send them instead of state) and repeat.
mini-mux server --replay-on-attach
# ...same procedure...
time mini-mux attach
# 3. And the correctness case: use vim, quit it, run top, detach for a while.
# Reattach in both modes and compare.
PREDICTION. Before running: how long does the replay reattach take after 5 minutes of top? After
an hour? What does the replayed screen look like if vim ran before top?
RESULT. Record the timings and screenshot both. This is the experiment that justifies the whole "server contains an emulator" design in one measurement.
Test
#![allow(unused)] fn main() { #[test] fn session_survives_client_detach_and_reattach() { let server = TestServer::spawn().unwrap(); let mut c1 = TestClient::attach(&server.socket()).unwrap(); let pane = c1.new_pane("bash").unwrap(); c1.type_line("echo MARKER"); c1.wait_for_text("MARKER", Duration::from_secs(2)); c1.detach(); std::thread::sleep(Duration::from_secs(1)); let c2 = TestClient::attach(&server.socket()).unwrap(); assert!(c2.screen_contains("MARKER"), "reattach must show the session's state"); assert_eq!(c2.pane_count(), 1); let _ = pane; } #[test] fn reattach_is_o_screen_not_o_elapsed_time() { // The state-vs-replay property, measured. let server = TestServer::spawn().unwrap(); let _pane = server.new_pane_direct("bash -c 'while :; do echo x; done'"); std::thread::sleep(Duration::from_secs(5)); // megabytes of output let t0 = Instant::now(); let c = TestClient::attach(&server.socket()).unwrap(); let elapsed = t0.elapsed(); assert!(elapsed < Duration::from_millis(200), "reattach took {elapsed:?}"); assert!(!c.screen().is_empty()); } #[test] fn reattach_is_correct_after_an_alternate_screen_program_exited() { // The case that breaks replay with a bounded log. let server = TestServer::spawn().unwrap(); let pane = server.new_pane_direct("bash"); server.send_input(pane, b"\x1b[?1049h"); // simulate entering alt server.send_input(pane, b"echo ALT\n"); server.send_input(pane, b"\x1b[?1049l"); // and leaving server.send_input(pane, b"echo PRIMARY\n"); server.pump(Duration::from_millis(500)); let c = TestClient::attach(&server.socket()).unwrap(); assert!(c.screen_contains("PRIMARY")); assert!(!c.screen_contains("ALT"), "alt-screen content must not leak"); } #[test] fn smallest_client_dictates_the_size() { let server = TestServer::spawn().unwrap(); let _big = TestClient::attach_with_size(&server.socket(), Size { rows: 50, cols: 200 }).unwrap(); let s = server.session_ids()[0]; assert_eq!(server.session_size(s), Size { rows: 50, cols: 200 }); let small = TestClient::attach_with_size(&server.socket(), Size { rows: 24, cols: 80 }).unwrap(); assert_eq!(server.session_size(s), Size { rows: 24, cols: 80 }); small.detach(); std::thread::sleep(Duration::from_millis(200)); assert_eq!(server.session_size(s), Size { rows: 50, cols: 200 }, "removing the constraint must restore the larger size"); } #[test] fn last_detach_does_not_resize_to_zero() { let server = TestServer::spawn().unwrap(); let c = TestClient::attach_with_size(&server.socket(), Size { rows: 24, cols: 80 }).unwrap(); let s = server.session_ids()[0]; c.detach(); std::thread::sleep(Duration::from_millis(200)); assert_eq!(server.session_size(s), Size { rows: 24, cols: 80 }); } #[test] fn two_clients_both_render_and_both_type() { let server = TestServer::spawn().unwrap(); let mut a = TestClient::attach(&server.socket()).unwrap(); let mut b = TestClient::attach(&server.socket()).unwrap(); a.type_line("echo FROM_A"); a.wait_for_text("FROM_A", Duration::from_secs(2)); assert!(b.screen_contains("FROM_A"), "both clients see the same state"); b.type_line("echo FROM_B"); b.wait_for_text("FROM_B", Duration::from_secs(2)); assert!(a.screen_contains("FROM_B")); } #[test] fn a_stalled_client_does_not_block_the_others() { let server = TestServer::spawn().unwrap(); let stalled = TestClient::attach_and_stop_reading(&server.socket()).unwrap(); let mut live = TestClient::attach(&server.socket()).unwrap(); server.new_pane("yes"); std::thread::sleep(Duration::from_secs(2)); live.type_line("echo STILL_WORKS"); assert!(live.wait_for_text("STILL_WORKS", Duration::from_secs(2))); let _ = stalled; } }
Challenge Extensions
attach -r(read-only): a client that renders but cannot type. Useful for demos and pairing.- Per-window sizing (tmux's
aggressive-resize) as a second policy, with a written comparison after actually using both. - Session persistence across server restarts: serialize the layout tree and pane commands so a restarted server can recreate the structure (not the processes). Be honest in the write-up about what cannot be restored.
- Attach over SSH:
ssh host -t mini-mux attach. Note which assumptions break (latency,TERM, size reporting) and fix them. - A client-side reconnect loop: if the socket drops, retry with backoff and repaint. Now a flaky SSH link does not lose your session view.
- Instrument the repaint cost: bytes sent on attach as a function of pane count and size. Compare with the replay approach.
Deliverables
- Clean detach that restores the client's terminal completely.
-
Attach that repaints from state, with
PaneFullper visible pane plus layout and focus. - The alternate-screen correctness test passing.
- A documented multi-client resize policy, implemented, with the no-op guard.
- The last-detach-keeps-size rule.
- Two clients attached simultaneously, both rendering and typing.
- A stalled client that does not affect the others.
- All seven tests passing.
- The state-vs-replay timing experiment, with numbers.
Validation / Self-check
- Why repaint from state rather than replaying bytes? Give the alternate-screen failure concretely.
- What is the cost of reattach in each approach, as a function of detach duration?
- What must the server send on attach, and what does the client need each piece for?
- Why does the client need
PaneModes? Name three modes and what breaks without each. - State your multi-client resize policy and defend it against the alternatives.
- Why must the last detach not resize the session?
- Why is focus session state while scroll position is per-client?
- What happens to a client that falls behind, and why is a full repaint the right recovery?
- Why does a stalled client not block the others? What two mechanisms guarantee it?
- Your timing experiment: how long did replay-based reattach take after five minutes of
top?
Section 4 Complete
You have a real multiplexer:
- A daemonized server owning every PTY master and a headless terminal per pane.
- Sessions, windows, panes, a layout tree, and compositing.
- A framed client/server protocol with a version handshake.
- Detach and attach that survive
kill -9, with correct state-based repaint. - Multiple simultaneous clients with a documented resize policy.
And the crate graph still forbids terminal-mux → terminal-gui, which is what made all of it
possible.
Next: Lab 19 — Copy Mode.