tear_core/inproc.rs
1//! `InProcess` — the in-memory [`tear_types::MultiplexerControl`]
2//! implementation backed by `parking_lot::RwLock<Registry>` +
3//! `BTreeMap<PaneId, PtyHandle>`.
4//!
5//! ## Architecture
6//!
7//! - **Registry** (`Arc<RwLock<Registry>>`) — pure typed state: which
8//! sessions / windows / panes exist, what their layouts are. Read
9//! by `list_sessions`, `get_*`; written by mutating ops.
10//! - **PTYs** (`Arc<Mutex<BTreeMap<PaneId, PtyHandle>>>`) — the
11//! physical PTY handles. Separated from the typed registry so a
12//! daemon can serialise the registry to disk for resurrection
13//! while PTYs (which can't outlive a process) stay in memory.
14//!
15//! ## Mado integration shape (M5)
16//!
17//! At M5 mado's `render::SharedTerminal` will become an
18//! `Arc<InProcess>` instead of an `Arc<RwLock<Terminal>>`. Each
19//! mado pane is then just a `PaneId` view over the shared
20//! `InProcess`; `mado_pane.feed(bytes)` delegates to
21//! `inproc.feed_pane_bytes(pane_id, bytes)` which calls the same
22//! `vte` parser the tear-daemon uses on the headless path.
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use parking_lot::{Mutex, RwLock};
28use portable_pty::PtySize;
29use tracing::{debug, info};
30
31use tear_types::{
32 ControlError, ControlResult, Direction, LayoutKind, LayoutNode, LeafRemoval,
33 MultiplexerControl, PaneId, Rect, SessionId, SplitOrientation, TearPane, TearSession,
34 TearWindow, WindowId,
35};
36
37use std::sync::mpsc;
38
39use crate::pane_grid::PaneGrid;
40use crate::pty::PtyHandle;
41use crate::reap::AllPanesExited;
42use crate::recording::PaneRecording;
43use crate::registry::Registry;
44
45/// Per-pane byte-stream fan-out state.
46///
47/// Co-locates the live subscriber senders with a `closed`
48/// end-of-stream marker so that *registering* a subscriber and
49/// *closing* the stream on child-exit are decided under a single
50/// lock. Without the co-located marker, a `subscribe` that races with
51/// (or follows) the pane's exit could push a sender that is never
52/// dropped — the exact "receiver blocks forever" failure this whole
53/// change exists to remove.
54#[derive(Default)]
55struct PaneSubscribers {
56 /// `Some(code)` once the pane's PTY child has exited; no further
57 /// bytes will ever be sent. New subscribers then receive an
58 /// already-disconnected receiver instead of a live registration.
59 closed: Option<i32>,
60 /// Live subscribers — each receives a clone of every PTY chunk.
61 senders: Vec<mpsc::Sender<Vec<u8>>>,
62}
63
64/// The native in-process multiplexer backend.
65pub struct InProcess {
66 registry: Arc<RwLock<Registry>>,
67 ptys: Arc<Mutex<BTreeMap<PaneId, PtyHandle>>>,
68 /// Per-pane VT parser + cell grid. Phase-2-MVP wires PTY bytes
69 /// into these so [`Self::pane_snapshot`] returns the rendered
70 /// state. Wrapped per-pane in `Mutex` so the PTY reader thread
71 /// and snapshot callers can race independently per pane.
72 grids: Arc<Mutex<BTreeMap<PaneId, Arc<Mutex<PaneGrid>>>>>,
73 /// Per-pane byte-stream fan-out state ([`PaneSubscribers`]): the
74 /// live subscriber senders plus a `closed` end-of-stream marker.
75 /// On send error the fan-out prunes dead subscribers; on child
76 /// exit [`Self::spawn_pty_for`]'s `on_exit` hook marks the entry
77 /// closed and drops the senders, so every [`mpsc::Receiver`]
78 /// disconnects — the end-of-stream signal mado's
79 /// `attach_live.run()` and the daemon's `serve_subscription`
80 /// block on.
81 subscribers: Arc<Mutex<BTreeMap<PaneId, PaneSubscribers>>>,
82 /// Per-pane recording (#4). Cheap when disabled — the on_bytes
83 /// hook hits a single boolean before deciding whether to
84 /// deep-copy the chunk. Recording is opt-in via
85 /// `enable_pane_recording`.
86 recordings: Arc<Mutex<BTreeMap<PaneId, Arc<PaneRecording>>>>,
87 /// UDS path the tear-daemon bound to. Stamped onto every PTY
88 /// child's `TEAR_SOCKET` env var so shells / prompts /
89 /// child processes can re-discover the daemon without
90 /// scanning the XDG runtime dir.
91 socket_path: Arc<RwLock<Option<std::path::PathBuf>>>,
92 /// Embedder-supplied env + cwd override (mado's typed capability
93 /// projection — `TERM=xterm-ghostty` + `TERMINFO` + `COLORTERM` +
94 /// `PWD`), applied to every child's env AFTER the inherited +
95 /// fallback env so the embedder's richer capability set wins over
96 /// the conservative `xterm-256color` default. Empty by default
97 /// (the pre-seam behaviour); set via [`Self::set_spawn_env`]. This
98 /// is the fix for "vim grey + wrong font in the embedded-tear
99 /// window" (operator report 2026-06-12).
100 spawn_env: Arc<RwLock<tear_types::SpawnEnv>>,
101}
102
103impl Default for InProcess {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109impl InProcess {
110 #[must_use]
111 pub fn new() -> Self {
112 Self {
113 registry: Arc::new(RwLock::new(Registry::new())),
114 ptys: Arc::new(Mutex::new(BTreeMap::new())),
115 grids: Arc::new(Mutex::new(BTreeMap::new())),
116 subscribers: Arc::new(Mutex::new(BTreeMap::new())),
117 recordings: Arc::new(Mutex::new(BTreeMap::new())),
118 socket_path: Arc::new(RwLock::new(None)),
119 spawn_env: Arc::new(RwLock::new(tear_types::SpawnEnv::none())),
120 }
121 }
122
123 /// Set the embedder's typed env + cwd override, applied to every
124 /// subsequent child PTY's env AFTER the inherited + fallback env.
125 /// mado calls this with its `caps::EnvProjection` pairs (+ the boot
126 /// cwd) so vim in an embedded-tear window sees `xterm-ghostty` +
127 /// truecolor + the vendored terminfo — identical to the local-PTY
128 /// path. Idempotent; the last write wins.
129 pub fn set_spawn_env(&self, env: tear_types::SpawnEnv) {
130 *self.spawn_env.write() = env;
131 }
132
133 /// The embedder's current spawn cwd override (`SpawnEnv.cwd`), if any.
134 ///
135 /// mado stamps this per-spawn (the focused pane's cwd / boot cwd)
136 /// before each `new_session`, so the daemon can read it at session-
137 /// create time to learn which directory a session was opened in —
138 /// the seed for praça's project↔session binding (M1 "Remember").
139 /// `None` when no embedder cwd override is set (the bare-daemon path).
140 #[must_use]
141 pub fn spawn_cwd(&self) -> Option<std::path::PathBuf> {
142 self.spawn_env
143 .read()
144 .cwd
145 .as_ref()
146 .map(std::path::PathBuf::from)
147 }
148
149 /// Record the UDS path the daemon bound to. Subsequent PTY
150 /// spawns stamp `TEAR_SOCKET=<path>` on the child env. Called
151 /// by `tear-daemon::start*` immediately after `bind`.
152 pub fn set_socket_path(&self, path: std::path::PathBuf) {
153 *self.socket_path.write() = Some(path);
154 }
155
156 /// Borrow the recorded socket path, if any.
157 pub fn socket_path(&self) -> Option<std::path::PathBuf> {
158 self.socket_path.read().clone()
159 }
160
161 /// Enable recording for `pane_id`. Idempotent — calling on an
162 /// already-enabled pane resets the recording buffer (per
163 /// `PaneRecording::enable` semantics). Reads the pane's
164 /// current size from the registry for the asciinema header.
165 pub fn enable_pane_recording(&self, pane_id: PaneId) -> ControlResult<()> {
166 let (cols, rows) = {
167 let r = self.registry.read();
168 let Some((sid, _wid)) = r.locate_pane(pane_id) else {
169 return Err(ControlError::NoSuchPane(pane_id));
170 };
171 let Some(p) = r.sessions.get(&sid).and_then(|s| s.panes.get(&pane_id)) else {
172 return Err(ControlError::NoSuchPane(pane_id));
173 };
174 p.size_cells
175 };
176 let mut recs = self.recordings.lock();
177 let rec = recs
178 .entry(pane_id)
179 .or_insert_with(|| Arc::new(PaneRecording::default()));
180 rec.enable(cols, rows);
181 Ok(())
182 }
183
184 /// Stop recording for `pane_id`. The captured buffer is
185 /// retained until the next `enable` or `kill_pane`; the
186 /// operator can still `export` after stopping.
187 pub fn disable_pane_recording(&self, pane_id: PaneId) -> ControlResult<()> {
188 let recs = self.recordings.lock();
189 match recs.get(&pane_id) {
190 Some(r) => {
191 r.disable();
192 Ok(())
193 }
194 None => Err(ControlError::NoSuchPane(pane_id)),
195 }
196 }
197
198 /// Export the pane's captured recording as asciinema v2
199 /// .cast (JSON-lines). Returns an empty string when nothing
200 /// has been captured yet (recording was never enabled or the
201 /// pane has no events).
202 pub fn export_pane_recording(&self, pane_id: PaneId) -> ControlResult<String> {
203 let recs = self.recordings.lock();
204 match recs.get(&pane_id) {
205 Some(r) => Ok(r.to_cast_json()),
206 None => Err(ControlError::NoSuchPane(pane_id)),
207 }
208 }
209
210 /// `(is_enabled, event_count)` snapshot — driven by the
211 /// pane-info / pane-record-status ergonomics.
212 pub fn pane_recording_status(&self, pane_id: PaneId) -> ControlResult<(bool, u32)> {
213 let recs = self.recordings.lock();
214 match recs.get(&pane_id) {
215 Some(r) => Ok((r.is_enabled(), r.event_count() as u32)),
216 None => Ok((false, 0)),
217 }
218 }
219
220 /// List captured blocks for a pane (oldest-first). Filters by
221 /// `since_index` — pass 0 to get every retained block. The
222 /// daemon currently caps at 10_000 blocks per pane (ring
223 /// eviction). Returns `NoSuchPane` if the pane has no grid.
224 pub fn pane_blocks_list(
225 &self,
226 pane_id: PaneId,
227 since_index: u64,
228 limit: u32,
229 ) -> ControlResult<Vec<crate::blocks::Block>> {
230 let grid_arc = {
231 let map = self.grids.lock();
232 map.get(&pane_id)
233 .cloned()
234 .ok_or(ControlError::NoSuchPane(pane_id))?
235 };
236 let grid = grid_arc.lock();
237 let out: Vec<crate::blocks::Block> = grid
238 .state
239 .blocks
240 .iter()
241 .filter(|b| b.index >= since_index)
242 .take(limit as usize)
243 .cloned()
244 .collect();
245 Ok(out)
246 }
247
248 /// Fetch one block by per-pane index. Returns NoSuchPane if
249 /// the pane is gone, or the InvalidArgument variant via
250 /// Rejected when the block has been evicted / never existed.
251 pub fn pane_block_at(
252 &self,
253 pane_id: PaneId,
254 index: u64,
255 ) -> ControlResult<crate::blocks::Block> {
256 let grid_arc = {
257 let map = self.grids.lock();
258 map.get(&pane_id)
259 .cloned()
260 .ok_or(ControlError::NoSuchPane(pane_id))?
261 };
262 let grid = grid_arc.lock();
263 grid.state
264 .blocks
265 .get(index)
266 .cloned()
267 .ok_or_else(|| ControlError::Rejected(format!(
268 "no block at index {index} (oldest evicted or never existed)"
269 )))
270 }
271
272 /// `(total_completed_blocks, current_in_progress)` — useful
273 /// for status displays. `tear top` reads this column.
274 pub fn pane_blocks_status(&self, pane_id: PaneId) -> ControlResult<(u32, bool)> {
275 let grid_arc = {
276 let map = self.grids.lock();
277 map.get(&pane_id)
278 .cloned()
279 .ok_or(ControlError::NoSuchPane(pane_id))?
280 };
281 let grid = grid_arc.lock();
282 Ok((
283 grid.state.blocks.len() as u32,
284 grid.state.blocks.current().is_some(),
285 ))
286 }
287
288 /// Register a byte-stream subscriber for the named pane.
289 /// Returns the receiver end of an `mpsc::channel`; every PTY
290 /// chunk that lands in this pane is sent on the corresponding
291 /// sender. Drop the receiver to unsubscribe — the next send
292 /// will error and the daemon prunes the dead sender.
293 ///
294 /// Returns `NoSuchPane` if the pane has no PTY (never spawned
295 /// or already killed).
296 ///
297 /// If the pane's child has already exited (remain-on-exit dead
298 /// pane), the returned receiver is born already-disconnected: no
299 /// live sender is registered, so the consumer can replay the
300 /// pane's final grid snapshot and then immediately observe
301 /// end-of-stream (`recv() -> Err`) instead of blocking forever on
302 /// a pane that will never emit again. The `closed` check and the
303 /// sender push happen under the same lock, so a `subscribe` that
304 /// races with the pane's exit can't leak a sender that never
305 /// disconnects.
306 pub fn subscribe_pane_bytes(
307 &self,
308 pane: PaneId,
309 ) -> ControlResult<mpsc::Receiver<Vec<u8>>> {
310 // Confirm the pane exists; we don't actually need the
311 // grid here (the sender is registered regardless), but
312 // subscribing to a phantom pane silently is a footgun.
313 if !self.ptys.lock().contains_key(&pane) {
314 return Err(ControlError::NoSuchPane(pane));
315 }
316 let (tx, rx) = mpsc::channel();
317 let mut subs = self.subscribers.lock();
318 let ps = subs.entry(pane).or_default();
319 if ps.closed.is_none() {
320 ps.senders.push(tx);
321 }
322 // else: stream already closed — drop `tx` here so `rx` is
323 // immediately disconnected.
324 Ok(rx)
325 }
326
327 /// Borrow the registry read-only — useful for callers that want
328 /// to scan multiple entities atomically without locking per-call.
329 pub fn with_registry<R>(&self, f: impl FnOnce(&Registry) -> R) -> R {
330 let r = self.registry.read();
331 f(&r)
332 }
333
334 /// Return a serializable snapshot of the named pane's rendered
335 /// grid. Returns `NoSuchPane` if the pane never had a grid
336 /// installed (which can only happen if it never had a PTY —
337 /// every PTY-spawning code path also installs a grid).
338 pub fn pane_snapshot(&self, pane_id: PaneId) -> ControlResult<tear_types::PaneSnapshot> {
339 let grid_arc = {
340 let map = self.grids.lock();
341 map.get(&pane_id)
342 .cloned()
343 .ok_or(ControlError::NoSuchPane(pane_id))?
344 };
345 let grid = grid_arc.lock();
346 Ok(grid.snapshot())
347 }
348
349 /// No-alloc DECCKM lookup — reads one `bool` off the live
350 /// `PaneGrid` rather than building a full `PaneSnapshot`.
351 /// Mado's embedded-tear input loop hits this on every arrow
352 /// keystroke; the snapshot path would clone the entire cell
353 /// grid (~100KB per call on an 80×40 pane).
354 pub fn pane_cursor_keys_mode(&self, pane_id: PaneId) -> ControlResult<bool> {
355 let grid_arc = {
356 let map = self.grids.lock();
357 map.get(&pane_id)
358 .cloned()
359 .ok_or(ControlError::NoSuchPane(pane_id))?
360 };
361 let grid = grid_arc.lock();
362 Ok(grid.cursor_keys_mode())
363 }
364
365 /// Spawn a PTY for the given pane. Caller pre-creates the typed
366 /// pane via the registry; this attaches the runtime + installs
367 /// the per-pane VT parser AND injects the `TEAR_*` env vars so
368 /// shells and prompts (starship) can see they're running inside
369 /// a tear session.
370 /// `args` is `shell`'s argv[1..], handed to [`PtyHandle::spawn`]
371 /// as an argument vector. It reaches `execvp` directly — there is
372 /// no shell in between, so no quoting or escaping applies.
373 /// `yurai` is threaded in rather than stamped by the caller
374 /// afterwards, deliberately: this is the ONE choke point every
375 /// pane's grid is created at, so taking provenance as a
376 /// parameter makes "spawn a pane whose blocks are unattributed"
377 /// unconstructible at the call site. A post-spawn stamp would be
378 /// a step someone can forget, and the failure would be silent —
379 /// blocks quietly reading `Unknown` forever.
380 fn spawn_pty_for(
381 &self,
382 pane_id: PaneId,
383 shell: &str,
384 args: &[String],
385 size: (u16, u16),
386 yurai: tear_types::Yurai,
387 ) -> anyhow::Result<()> {
388 // Typed cross-tool env-var names (the SAME source seki's prompt
389 // reads) — hoisted to the top of the fn so it's an item, not a
390 // statement-position import.
391 use ishou_tokens::FleetStateVar as Fsv;
392 let pty_size = PtySize {
393 rows: size.1,
394 cols: size.0,
395 pixel_width: 0,
396 pixel_height: 0,
397 };
398 // Resolve the session this pane belongs to so we can stamp
399 // TEAR_SESSION_{ID,NAME} on the child's env. Look-up is
400 // cheap (BTreeMap walk over typically <10 sessions); the
401 // alternative — caller threading session_id in — would
402 // bloat every call site.
403 let (session_id, session_name) = {
404 let r = self.registry.read();
405 r.sessions
406 .values()
407 .find(|s| s.panes.contains_key(&pane_id))
408 .map(|s| (s.id.to_string(), s.name.clone()))
409 .unwrap_or_else(|| (String::new(), String::new()))
410 };
411 // portable_pty's CommandBuilder uses an explicit env-vec
412 // — any env we pass REPLACES the parent process's env
413 // rather than augmenting it. The tear-daemon typically
414 // runs under launchd (macOS) / systemd-user (Linux) with
415 // a minimal env, so we MUST inherit the daemon's env
416 // first (which carries PATH from blackmatter-shell's
417 // session-vars), THEN stamp TEAR_* on top, THEN ensure
418 // TERM is set so terminfo-based programs (`clear`, vi,
419 // anything that reads $TERM) work.
420 let mut env: Vec<(String, String)> = std::env::vars().collect();
421 // The env-var NAMES come from the typed cross-tool contract
422 // (`Fsv`, hoisted above) — the SAME source seki's prompt reads,
423 // so a rename is a compile-time change on both sides. The VALUES
424 // are unchanged.
425 env.push(("TEAR".into(), "1".into()));
426 env.push((Fsv::TearSessionId.name().into(), session_id));
427 env.push((Fsv::TearSessionName.name().into(), session_name));
428 env.push((Fsv::TearPaneId.name().into(), pane_id.to_string()));
429 if let Some(p) = self.socket_path() {
430 env.push((
431 Fsv::TearSocket.name().into(),
432 p.to_string_lossy().to_string(),
433 ));
434 }
435 // TERM fallback — if the daemon was spawned by launchd
436 // and doesn't have TERM set, every shell inside tear
437 // would see `TERM environment variable not set` and
438 // `clear` / `tput` / readline arrow keys would break.
439 // xterm-256color is the modern conservative default.
440 if !env.iter().any(|(k, _)| k == "TERM") {
441 env.push(("TERM".into(), "xterm-256color".into()));
442 }
443 // COLORTERM advertises 24-bit colour support to apps
444 // that opt-in (newer vim/neovim, modern btop, etc.).
445 if !env.iter().any(|(k, _)| k == "COLORTERM") {
446 env.push(("COLORTERM".into(), "truecolor".into()));
447 }
448 // PATH augmentation — when the daemon is spawned by
449 // launchd (macOS) / systemd-user (Linux), its inherited
450 // PATH is the minimal `/usr/bin:/bin:/usr/sbin:/sbin`.
451 // Shells that try to invoke `tear` from a starship custom
452 // block, or any home-manager-installed binary, fail —
453 // and starship's prompt rendering hangs / errors silently.
454 // We prepend the operator's home-manager + nix-profile
455 // bin dirs to whatever PATH was inherited so the shell
456 // can find the same binaries the user sees outside tear.
457 if let Some(home) = env.iter().find(|(k, _)| k == "HOME").map(|(_, v)| v.clone()) {
458 let user = env
459 .iter()
460 .find(|(k, _)| k == "USER")
461 .map(|(_, v)| v.clone())
462 .unwrap_or_default();
463 let extra_paths = [
464 format!("/etc/profiles/per-user/{user}/bin"),
465 format!("{home}/.nix-profile/bin"),
466 "/run/current-system/sw/bin".to_string(),
467 "/nix/var/nix/profiles/default/bin".to_string(),
468 "/usr/local/bin".to_string(),
469 ];
470 // Find existing PATH entry to prepend to; if missing,
471 // build PATH from scratch with sensible defaults.
472 let existing_path = env
473 .iter()
474 .find(|(k, _)| k == "PATH")
475 .map(|(_, v)| v.clone())
476 .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
477 // Prepend extras that aren't already in PATH (de-dupe
478 // so we don't bloat PATH on every nested spawn).
479 let mut new_path = String::new();
480 for p in &extra_paths {
481 if !existing_path
482 .split(':')
483 .any(|seg| seg == p.as_str())
484 {
485 if !new_path.is_empty() {
486 new_path.push(':');
487 }
488 new_path.push_str(p);
489 }
490 }
491 if !new_path.is_empty() {
492 new_path.push(':');
493 new_path.push_str(&existing_path);
494 } else {
495 new_path = existing_path;
496 }
497 // Replace existing PATH entry (or append if missing).
498 if let Some(slot) = env.iter_mut().find(|(k, _)| k == "PATH") {
499 slot.1 = new_path;
500 } else {
501 env.push(("PATH".into(), new_path));
502 }
503 }
504 // Embedder env + cwd override (mado's capability projection),
505 // applied LAST so its TERM=xterm-ghostty + TERMINFO + COLORTERM
506 // win over the xterm-256color fallback above (the "vim grey"
507 // fix), and PWD is stamped to match the cwd. Empty pre-seam.
508 let spawn_env = self.spawn_env.read().clone();
509 spawn_env.apply_to(&mut env);
510 let cwd = spawn_env.cwd.clone();
511 // Allocate the per-pane grid and register it BEFORE spawning
512 // the PTY — the reader thread starts immediately on spawn,
513 // and we want the first bytes to find their grid.
514 let grid = Arc::new(Mutex::new(PaneGrid::new(size.0 as usize, size.1 as usize)));
515 // Stamp provenance BEFORE the grid is registered, so the
516 // first byte the reader thread feeds already lands in an
517 // attributed extractor. Registering first would leave a
518 // window in which a fast-printing shell mints `Unknown`
519 // blocks for a pane whose provenance we already knew.
520 grid.lock().stamp_yurai(yurai);
521 self.grids.lock().insert(pane_id, grid.clone());
522
523 let grid_for_callback = grid.clone();
524 let subscribers_for_callback = self.subscribers.clone();
525 let recordings_for_callback = self.recordings.clone();
526 let on_bytes = Box::new(move |bytes: &[u8]| {
527 grid_for_callback.lock().feed(bytes);
528 // Fan out to subscribers (Phase-2.5 push subscriptions).
529 // Cheap when there are zero subscribers; per-subscriber
530 // cost is a Vec clone + mpsc::send. On send error the
531 // sender is dead — prune it.
532 let mut subs = subscribers_for_callback.lock();
533 if let Some(ps) = subs.get_mut(&pane_id) {
534 let senders = &mut ps.senders;
535 let mut i = 0;
536 while i < senders.len() {
537 if senders[i].send(bytes.to_vec()).is_err() {
538 senders.swap_remove(i);
539 } else {
540 i += 1;
541 }
542 }
543 }
544 drop(subs);
545 // Push to the recording (#4). The Arc-cloned
546 // recording handle's `push` is a single Mutex-lock
547 // + early return when disabled, so this is cheap
548 // even when nothing's recording.
549 if let Some(rec) = recordings_for_callback.lock().get(&pane_id) {
550 rec.push(bytes);
551 }
552 debug!(pane_id = %pane_id, n = bytes.len(), "tear-core: pty bytes fed to grid + subscribers");
553 });
554 // on_exit — fired once by the PTY reader thread when the child
555 // exits (PTY EOF). Three typed consequences:
556 //
557 // 1. Mark the pane `PaneState::Exited { code }` in the typed
558 // registry. A *watched* pane + its final grid stay (tmux
559 // remain-on-exit) so `tear list` / snapshots still see it;
560 // only the live byte stream ends.
561 // 2. Mark the subscriber entry `closed` + drop every live
562 // sender. Each engate/daemon `Receiver.recv()` then
563 // returns `Err` — the end-of-stream signal mado's
564 // `attach_live.run()` and the daemon's `serve_subscription`
565 // block on. Without this the channel stays open forever and
566 // a single-pane GUI (mado embedded) never learns the shell
567 // exited, so its window never closes.
568 // 3. Reap-on-exit (session-leak fix, 2026-07-06): when EVERY
569 // pane of the owning session has exited AND no pane carried
570 // a live subscriber at exit time (nothing was watching),
571 // the whole session is removed from the registry and its
572 // runtime artifacts are detached. Without this, sessions
573 // whose shell exited unwatched (agent spawns that never
574 // attached, one-shot commands) linger in the registry
575 // forever — surfacing as ghost rows in mado's Ctrl-S
576 // picker. Conservative by construction: any live sender on
577 // any pane of the session (a mado window, a recorder)
578 // preserves the remain-on-exit behavior unchanged, and a
579 // session with any Running/Spawning pane is never touched.
580 //
581 // Lock order matches the kill paths (the registry write, the
582 // subscribers lock, and the reap's detach scope are separate
583 // critical sections — never nested — so no inversion). The
584 // subscribers step is gated on the pane still being present in
585 // the registry so an explicit `kill_pane` that races with
586 // natural exit doesn't leave a lingering empty entry. The reap
587 // runs on this pane's own reader thread, which is safe: on the
588 // natural-exit path the child slot is already `take()`n +
589 // reaped (so `PtyHandle::drop` skips the kill) and every
590 // sibling pane is `Exited` (their readers finished), and the
591 // handle drops happen with NO `InProcess` lock held
592 // (`detach_panes`' deadlock contract).
593 let subscribers_for_exit = Arc::clone(&self.subscribers);
594 let registry_for_exit = Arc::clone(&self.registry);
595 let ptys_for_exit = Arc::clone(&self.ptys);
596 let grids_for_exit = Arc::clone(&self.grids);
597 let recordings_for_exit = Arc::clone(&self.recordings);
598 let on_exit = Box::new(move |code: Option<i32>| {
599 let (still_present, fully_exited) = {
600 let mut r = registry_for_exit.write();
601 let mut found = false;
602 let mut fully: Option<SessionId> = None;
603 for s in r.sessions.values_mut() {
604 if let Some(p) = s.panes.get_mut(&pane_id) {
605 p.state = tear_types::PaneState::Exited {
606 code: code.unwrap_or(-1),
607 };
608 found = true;
609 if s.panes.values().all(|p| {
610 matches!(p.state, tear_types::PaneState::Exited { .. })
611 }) {
612 fully = Some(s.id);
613 }
614 break;
615 }
616 }
617 (found, fully)
618 };
619 // `watched` = any pane of the (fully-exited) session still
620 // carried a live sender at exit time — this pane's senders
621 // inspected BEFORE the clear, siblings' as they stand
622 // (their own on_exit already cleared them if they exited
623 // unwatched). Watched sessions keep remain-on-exit.
624 let watched = {
625 let mut subs = subscribers_for_exit.lock();
626 let this_pane_watched = if still_present {
627 let ps = subs.entry(pane_id).or_default();
628 let had_live_sender = !ps.senders.is_empty();
629 ps.closed = Some(code.unwrap_or(-1));
630 ps.senders.clear();
631 had_live_sender
632 } else {
633 // Pane was explicitly killed concurrently — drop any
634 // entry rather than recreating one for a dead id.
635 subs.remove(&pane_id);
636 true
637 };
638 this_pane_watched
639 || fully_exited.is_some_and(|sid| {
640 registry_for_exit.read().sessions.get(&sid).is_some_and(|s| {
641 s.panes.keys().any(|p| {
642 *p != pane_id
643 && subs.get(p).is_some_and(|ps| !ps.senders.is_empty())
644 })
645 })
646 })
647 };
648 debug!(pane_id = %pane_id, ?code, "tear-core: pane child exited — marked Exited + disconnected subscribers");
649 if let Some(sid) = fully_exited.filter(|_| !watched) {
650 // The proof is re-taken under the write lock inside
651 // `reap_proven_dead`, so a window/pane spawned into the
652 // session between the mark and here (or a concurrent
653 // explicit kill) aborts the reap.
654 //
655 // BOUND TO A `let` ON PURPOSE — do not inline this into
656 // the `if let` scrutinee. Rust 2024's if-let rescoping
657 // drops scrutinee temporaries before the `else` arm but
658 // NOT before the success arm, so the read guard would
659 // still be alive inside the block and `reap_proven_dead`'s
660 // `registry.write()` would wait forever on a reader held
661 // by its own thread. Measured 2026-07-31: the daemon
662 // wedged on the first pane whose child exited, with
663 // `tear-pty-reader` parked in `wait_for_readers` and
664 // every later `list_sessions` parked behind it.
665 let proof = registry_for_exit
666 .read()
667 .sessions
668 .get(&sid)
669 .and_then(AllPanesExited::witness);
670 if let Some(proof) = proof {
671 reap_proven_dead(
672 ®istry_for_exit,
673 &ptys_for_exit,
674 &grids_for_exit,
675 &subscribers_for_exit,
676 &recordings_for_exit,
677 proof,
678 );
679 }
680 }
681 });
682 let pty = PtyHandle::spawn(
683 shell,
684 args,
685 cwd.as_deref(),
686 &env,
687 pty_size,
688 on_bytes,
689 on_exit,
690 )?;
691 self.ptys.lock().insert(pane_id, pty);
692 // Reap-race guard: a child that exits faster than this insert
693 // lands may already have been reaped (session gone from the
694 // registry) — pull the handle straight back out so a dead
695 // session can't strand a PtyHandle in the map. The handle (if
696 // any) drops outside every lock, per `detach_panes`' contract.
697 let orphaned = {
698 let in_registry = self
699 .registry
700 .read()
701 .sessions
702 .values()
703 .any(|s| s.panes.contains_key(&pane_id));
704 if in_registry {
705 None
706 } else {
707 self.grids.lock().remove(&pane_id);
708 self.subscribers.lock().remove(&pane_id);
709 self.recordings.lock().remove(&pane_id);
710 self.ptys.lock().remove(&pane_id)
711 }
712 };
713 drop(orphaned);
714 Ok(())
715 }
716
717 /// Detach every runtime artifact for `panes` — PTY handle, VT grid,
718 /// subscriber fan-out — under the three map locks, RETURNING the
719 /// PTY handles instead of dropping them. Callers drop the returned
720 /// vec only after every `InProcess` lock is released.
721 ///
722 /// DEADLOCK CONTRACT (mado L1 teardown wedge, 2026-06-10):
723 /// dropping a [`PtyHandle`] kills + reaps the child, and the
724 /// pane's `tear-pty-reader` thread may simultaneously be blocked
725 /// acquiring `subscribers` (inside `on_bytes`) or `registry`
726 /// (inside `on_exit`). Dropping the handle while this thread holds
727 /// those locks is a mutual wait: the reap can't finish until the
728 /// reader drains, the reader can't drain until the locks release —
729 /// observed as a 20+ minute wedge. The handles therefore ALWAYS
730 /// leave the maps inside the lock scope and die outside it (the
731 /// reap itself is additionally bounded — see `pty::reap_with_deadline`).
732 fn detach_panes(&self, panes: &[PaneId]) -> Vec<PtyHandle> {
733 let detached = {
734 let mut ptys = self.ptys.lock();
735 let mut grids = self.grids.lock();
736 let mut subs = self.subscribers.lock();
737 let mut detached = Vec::with_capacity(panes.len());
738 for p in panes {
739 if let Some(h) = ptys.remove(p) {
740 detached.push(h);
741 }
742 grids.remove(p);
743 // Dropping the sender vec disconnects subscribers
744 // cleanly — their recv() returns Err on next read.
745 subs.remove(p);
746 }
747 detached
748 };
749 // Drop the recording buffer as well — see the note in
750 // `reap_proven_dead` for why this is a separate scope taken after
751 // the three-map region rather than a fourth lock inside it.
752 {
753 let mut recs = self.recordings.lock();
754 for p in panes {
755 recs.remove(p);
756 }
757 }
758 detached
759 }
760
761 /// Remove a session the caller has **proven** dead — every pane's
762 /// child process has exited.
763 ///
764 /// This is the *only* way for a daemon to end a session on its own
765 /// initiative, and [`AllPanesExited`] is the only ticket in. A rule
766 /// keyed on "nobody is attached", on an idle timer, or on the
767 /// session's [`tear_types::SessionSource`] cannot construct one —
768 /// see [`crate::reap`] for the incident that motivated the narrowing.
769 ///
770 /// Returns `true` if the session was removed. `false` means the proof
771 /// went stale between witnessing and now (a `new_window` landed, or
772 /// an explicit `kill_session` won the race) — the proof is re-taken
773 /// under the write lock, so a resurrected session is never reaped.
774 ///
775 /// **Call with no registry guard held.** Mint the proof from an owned
776 /// [`TearSession`] (`list_sessions()` / `get_session()` both clone) or
777 /// from a `read()` bound to its own `let` statement. Passing a proof
778 /// witnessed inside a still-live read guard deadlocks on the `write()`
779 /// below — `parking_lot`'s `RwLock` is not reentrant and this thread
780 /// would be waiting for itself.
781 pub fn reap_proven_dead_session(&self, proof: AllPanesExited) -> bool {
782 reap_proven_dead(
783 &self.registry,
784 &self.ptys,
785 &self.grids,
786 &self.subscribers,
787 &self.recordings,
788 proof,
789 )
790 }
791}
792
793/// Free-standing so both `spawn_pty_for`'s `on_exit` hook (which owns
794/// cloned `Arc`s, not a `&self`) and [`InProcess::reap_proven_dead_session`]
795/// share one implementation — the session-removal path exists once.
796///
797/// Same shape as [`InProcess::detach_panes`]: artifacts leave the maps
798/// under the locks, handles die outside them (that deadlock contract).
799fn reap_proven_dead(
800 registry: &RwLock<Registry>,
801 ptys: &Mutex<BTreeMap<PaneId, PtyHandle>>,
802 grids: &Mutex<BTreeMap<PaneId, Arc<Mutex<PaneGrid>>>>,
803 subscribers: &Mutex<BTreeMap<PaneId, PaneSubscribers>>,
804 recordings: &Mutex<BTreeMap<PaneId, Arc<PaneRecording>>>,
805 proof: AllPanesExited,
806) -> bool {
807 let sid = proof.session();
808 let panes_to_detach: Vec<PaneId> = {
809 let mut r = registry.write();
810 // Re-witness under the write lock: the caller's proof was taken
811 // outside it and a racing `new_window` / `split_pane` /
812 // `kill_session` invalidates it.
813 if r.sessions
814 .get(&sid)
815 .and_then(AllPanesExited::witness)
816 .is_some()
817 {
818 r.sessions
819 .remove(&sid)
820 .map(|s| s.panes.keys().copied().collect())
821 .unwrap_or_default()
822 } else {
823 Vec::new()
824 }
825 };
826 if panes_to_detach.is_empty() {
827 return false;
828 }
829 let detached: Vec<PtyHandle> = {
830 let mut ptys = ptys.lock();
831 let mut grids = grids.lock();
832 let mut subs = subscribers.lock();
833 panes_to_detach
834 .iter()
835 .filter_map(|p| {
836 grids.remove(p);
837 subs.remove(p);
838 ptys.remove(p)
839 })
840 .collect()
841 };
842 // Drop the recording buffer too. Taken in its OWN scope, after the
843 // three-map scope above, deliberately: the pty reader acquires
844 // `subscribers` and `recordings` sequentially (it drops `subs` before
845 // taking `recordings` in `on_bytes`), so keeping this outside the
846 // triple-lock region adds no new lock-order edge to the deadlock
847 // contract on `detach_panes`. The `Arc<PaneRecording>` keeps any
848 // in-flight `push` alive until it returns.
849 {
850 let mut recs = recordings.lock();
851 for p in &panes_to_detach {
852 recs.remove(p);
853 }
854 }
855 drop(detached);
856 info!(session = %sid, "tear-core: reaped fully-exited unwatched session");
857 true
858}
859
860impl MultiplexerControl for InProcess {
861 fn list_sessions(&self) -> ControlResult<Vec<TearSession>> {
862 Ok(self.registry.read().sessions_in_order())
863 }
864
865 fn get_session(&self, id: SessionId) -> ControlResult<TearSession> {
866 self.registry
867 .read()
868 .sessions
869 .get(&id)
870 .cloned()
871 .ok_or(ControlError::NoSuchSession(id))
872 }
873
874 fn get_window(&self, id: WindowId) -> ControlResult<(SessionId, TearWindow)> {
875 let r = self.registry.read();
876 for s in r.sessions.values() {
877 if let Some(w) = s.windows.get(&id) {
878 return Ok((s.id, w.clone()));
879 }
880 }
881 Err(ControlError::NoSuchWindow(id))
882 }
883
884 fn get_pane(&self, id: PaneId) -> ControlResult<TearPane> {
885 let r = self.registry.read();
886 for s in r.sessions.values() {
887 if let Some(p) = s.panes.get(&id) {
888 return Ok(p.clone());
889 }
890 }
891 Err(ControlError::NoSuchPane(id))
892 }
893
894 fn new_session_with_source_and_size(
895 &self,
896 name: &str,
897 shell: &str,
898 args: &[String],
899 source: tear_types::SessionSource,
900 size_cells: (u16, u16),
901 ) -> ControlResult<SessionId> {
902 self.new_session_yurai(name, shell, args, source, size_cells, tear_types::Yurai::Unknown)
903 }
904
905 fn new_window(&self, session: SessionId, name: &str, shell: &str, args: &[String])
906 -> ControlResult<WindowId>
907 {
908 self.new_window_yurai(session, name, shell, args, tear_types::Yurai::Unknown)
909 }
910
911 fn split_pane(
912 &self,
913 origin: PaneId,
914 direction: tear_types::Direction,
915 shell: &str,
916 args: &[String],
917 ) -> ControlResult<PaneId> {
918 self.split_pane_yurai(origin, direction, shell, args, tear_types::Yurai::Unknown)
919 }
920
921
922 fn rename_session(&self, id: SessionId, new_name: &str) -> ControlResult<()> {
923 let mut r = self.registry.write();
924 let s = r.sessions.get_mut(&id).ok_or(ControlError::NoSuchSession(id))?;
925 s.name = new_name.into();
926 Ok(())
927 }
928
929 fn kill_session(&self, id: SessionId) -> ControlResult<()> {
930 let panes_to_kill: Vec<PaneId> = {
931 let r = self.registry.read();
932 let s = r.sessions.get(&id).ok_or(ControlError::NoSuchSession(id))?;
933 s.panes.keys().copied().collect()
934 };
935 // Pull the runtime artifacts out under the locks…
936 let detached = self.detach_panes(&panes_to_kill);
937 self.registry.write().sessions.remove(&id);
938 // …and kill + reap the PTY children with NO InProcess lock
939 // held (detach_panes' deadlock contract).
940 drop(detached);
941 info!(session = %id, "tear-core: killed session");
942 Ok(())
943 }
944
945
946 fn kill_window(&self, id: WindowId) -> ControlResult<()> {
947 let panes_to_kill: Vec<PaneId> = {
948 let r = self.registry.read();
949 let mut out = Vec::new();
950 for s in r.sessions.values() {
951 if let Some(w) = s.windows.get(&id) {
952 out.extend(w.layout.panes());
953 break;
954 }
955 }
956 if out.is_empty() {
957 return Err(ControlError::NoSuchWindow(id));
958 }
959 out
960 };
961 // Same shape as kill_session: artifacts leave the maps under
962 // the locks, handles die only after every lock is released
963 // (detach_panes' deadlock contract).
964 let detached = self.detach_panes(&panes_to_kill);
965 {
966 let mut r = self.registry.write();
967 for s in r.sessions.values_mut() {
968 if s.windows.remove(&id).is_some() {
969 for p in &panes_to_kill {
970 s.panes.remove(p);
971 }
972 // Retarget focus off the removed window, else
973 // active_window dangles and the s.windows[&active_window]
974 // index sites panic.
975 if s.active_window == id {
976 s.active_window =
977 s.windows.keys().next().copied().unwrap_or(WindowId::NULL);
978 }
979 break;
980 }
981 }
982 }
983 drop(detached);
984 info!(window = %id, "tear-core: killed window");
985 Ok(())
986 }
987
988 fn select_window(&self, id: WindowId) -> ControlResult<()> {
989 let mut r = self.registry.write();
990 for s in r.sessions.values_mut() {
991 if s.windows.contains_key(&id) {
992 s.active_window = id;
993 return Ok(());
994 }
995 }
996 Err(ControlError::NoSuchWindow(id))
997 }
998
999
1000 fn kill_pane(&self, id: PaneId) -> ControlResult<()> {
1001 // NOTE pre-fix this was `self.ptys.lock().remove(&id);` — the
1002 // returned PtyHandle was a temporary dropped BEFORE the lock
1003 // guard (reverse creation order), i.e. the kill + reap ran
1004 // with the ptys lock held. Same wedge class as kill_session;
1005 // same cure: detach under the locks, drop outside them.
1006 let Some((sid, wid)) = self.registry.read().locate_pane(id) else {
1007 return Err(ControlError::NoSuchPane(id));
1008 };
1009 let detached = self.detach_panes(&[id]);
1010 // true → pane removed from a multi-pane window, geometry needs a
1011 // reflow; false → the window collapsed (was its last pane) so
1012 // there's nothing left to reflow.
1013 let needs_reflow = {
1014 let mut r = self.registry.write();
1015 let Some(s) = r.sessions.get_mut(&sid) else {
1016 drop(detached);
1017 return Err(ControlError::NoSuchSession(sid));
1018 };
1019 let outcome = match s.windows.get_mut(&wid) {
1020 Some(w) => w.layout.remove_leaf(id),
1021 None => LeafRemoval::NotFound,
1022 };
1023 match outcome {
1024 // NotFound means `locate_pane` said this pane was in BOTH
1025 // `s.panes` and the window's tree, and then `remove_leaf`
1026 // could not find it in the tree — i.e. the two had already
1027 // diverged. That is structural corruption, and it used to
1028 // share an arm with the normal close: the record was
1029 // silently dropped and the caller got Ok(()), so a real bug
1030 // was indistinguishable from a routine kill.
1031 //
1032 // Roll the record back in and refuse. NoSuchPane already
1033 // exists and already round-trips over the wire, so this
1034 // costs no new type and no wire churn.
1035 LeafRemoval::NotFound => {
1036 drop(detached);
1037 return Err(ControlError::NoSuchPane(id));
1038 }
1039 // Parent split collapsed into the sibling — the flat pane
1040 // record goes too, and active_pane retargets if it pointed
1041 // at the dead pane.
1042 LeafRemoval::Removed => {
1043 s.panes.remove(&id);
1044 if let Some(w) = s.windows.get_mut(&wid) {
1045 if w.active_pane == id {
1046 w.active_pane =
1047 w.layout.panes().first().copied().unwrap_or(PaneId::NULL);
1048 }
1049 }
1050 true
1051 }
1052 // The window's only pane — nothing the tree can represent
1053 // remains, so the whole window closes (tmux semantics).
1054 // Retarget active_window if it pointed here, else it would
1055 // dangle at a removed id and panic the
1056 // s.windows[&active_window] index sites.
1057 LeafRemoval::WasRoot => {
1058 s.panes.remove(&id);
1059 s.windows.remove(&wid);
1060 if s.active_window == wid {
1061 s.active_window = s.windows.keys().next().copied().unwrap_or(WindowId::NULL);
1062 }
1063 false
1064 }
1065 }
1066 };
1067 drop(detached);
1068 if needs_reflow {
1069 self.apply_layout_geometry(sid, wid);
1070 }
1071 Ok(())
1072 }
1073
1074 fn select_pane(&self, id: PaneId) -> ControlResult<()> {
1075 let (sid, wid) = self
1076 .registry
1077 .read()
1078 .locate_pane(id)
1079 .ok_or(ControlError::NoSuchPane(id))?;
1080 let mut r = self.registry.write();
1081 if let Some(s) = r.sessions.get_mut(&sid) {
1082 if let Some(w) = s.windows.get_mut(&wid) {
1083 w.active_pane = id;
1084 }
1085 }
1086 Ok(())
1087 }
1088
1089 fn resize_pane(
1090 &self,
1091 id: PaneId,
1092 direction: Direction,
1093 delta_cells: i16,
1094 ) -> ControlResult<()> {
1095 // Slide the divider of the split governing `id` along `direction`.
1096 // delta_cells is converted to a fraction of the window's span on
1097 // the relevant axis, then resize_leaf clamps + applies it. If the
1098 // pane has no governing split that way (single pane / wrong axis)
1099 // it's a no-op — still Ok, like tmux.
1100 let (sid, wid) = self
1101 .registry
1102 .read()
1103 .locate_pane(id)
1104 .ok_or(ControlError::NoSuchPane(id))?;
1105 {
1106 let mut r = self.registry.write();
1107 let Some(s) = r.sessions.get_mut(&sid) else {
1108 return Err(ControlError::NoSuchSession(sid));
1109 };
1110 let Some(w) = s.windows.get_mut(&wid) else {
1111 return Err(ControlError::NoSuchWindow(wid));
1112 };
1113 let span = match direction.orientation() {
1114 SplitOrientation::Vertical => w.size_cells.0,
1115 SplitOrientation::Horizontal => w.size_cells.1,
1116 };
1117 let delta_frac = if span > 0 {
1118 f32::from(delta_cells) / f32::from(span)
1119 } else {
1120 0.0
1121 };
1122 w.layout.resize_leaf(id, direction, delta_frac);
1123 }
1124 self.apply_layout_geometry(sid, wid);
1125 Ok(())
1126 }
1127
1128 fn apply_layout(&self, window: WindowId, kind: LayoutKind) -> ControlResult<()> {
1129 // Locate the window's session under a read lock; release it before
1130 // taking the write lock (the lock discipline this file keeps).
1131 let sid = {
1132 let r = self.registry.read();
1133 r.sessions
1134 .iter()
1135 .find(|(_, s)| s.windows.contains_key(&window))
1136 .map(|(id, _)| *id)
1137 }
1138 .ok_or(ControlError::NoSuchWindow(window))?;
1139 // Re-arrange the window's existing panes (display order) into the
1140 // named layout. Panes keep their PTYs — only the tree changes.
1141 // Custom / empty → from_kind returns None → no-op.
1142 let applied = {
1143 let mut r = self.registry.write();
1144 let Some(s) = r.sessions.get_mut(&sid) else {
1145 return Err(ControlError::NoSuchWindow(window));
1146 };
1147 let Some(w) = s.windows.get_mut(&window) else {
1148 return Err(ControlError::NoSuchWindow(window));
1149 };
1150 match LayoutNode::from_kind(kind, &w.layout.panes()) {
1151 Some(tree) => {
1152 w.layout = tree;
1153 true
1154 }
1155 None => false,
1156 }
1157 };
1158 if applied {
1159 self.apply_layout_geometry(sid, window);
1160 }
1161 info!(window = %window, ?kind, applied, "tear-core: apply layout");
1162 Ok(())
1163 }
1164
1165 fn send_keys(&self, id: PaneId, bytes: &[u8]) -> ControlResult<()> {
1166 // Input-policy gate (#2).
1167 //
1168 // - Locked: always reject — operator-explicit "no input
1169 // now". Surfaced before we touch the PTY so a Locked
1170 // pane never writes a partial frame.
1171 // - Leader: identity-gating semantics are enforced ONE
1172 // layer up by the daemon's serve_connection_with_auth
1173 // path (which carries per-connection client_id). The
1174 // in-process trait surface has no client identity, so
1175 // Leader is treated as Free here — once the daemon's
1176 // gate authorises a SendKeys, this layer accepts. Pure
1177 // in-process consumers (mado tier-3) that need Leader
1178 // semantics must gate at their own layer.
1179 {
1180 let r = self.registry.read();
1181 let Some((sid, _wid)) = r.locate_pane(id) else {
1182 return Err(ControlError::NoSuchPane(id));
1183 };
1184 let Some(pane) = r.sessions.get(&sid).and_then(|s| s.panes.get(&id)) else {
1185 return Err(ControlError::NoSuchPane(id));
1186 };
1187 if matches!(pane.input_policy, tear_types::InputPolicy::Locked) {
1188 return Err(ControlError::Rejected(format!(
1189 "pane {id} input_policy=locked — send_keys rejected"
1190 )));
1191 }
1192 }
1193 let ptys = self.ptys.lock();
1194 let pty = ptys.get(&id).ok_or(ControlError::NoSuchPane(id))?;
1195 pty.write(bytes)
1196 .map_err(|e| ControlError::Transport(e.to_string()))?;
1197 Ok(())
1198 }
1199
1200 fn pane_subscriber_count(&self, id: PaneId) -> ControlResult<u32> {
1201 // Mirrors the byte-stream fan-out path: subscribers are
1202 // indexed per-pane in InProcess.subscribers. Counting +
1203 // sender liveness check (try_send via a synthetic
1204 // empty-byte cycle would be too heavy) means we just
1205 // report the slot length — a sender drops naturally on
1206 // next broadcast if dead, so the count is an upper bound.
1207 let count = self
1208 .subscribers
1209 .lock()
1210 .get(&id)
1211 .map(|ps| ps.senders.len() as u32)
1212 .unwrap_or(0);
1213 Ok(count)
1214 }
1215
1216 fn set_input_policy(
1217 &self,
1218 id: PaneId,
1219 policy: tear_types::InputPolicy,
1220 ) -> ControlResult<()> {
1221 let mut r = self.registry.write();
1222 // Walk the session→panes maps to find the target. Mirrors
1223 // locate_pane's address logic but with mutable access.
1224 for s in r.sessions.values_mut() {
1225 if let Some(p) = s.panes.get_mut(&id) {
1226 p.input_policy = policy;
1227 return Ok(());
1228 }
1229 }
1230 Err(ControlError::NoSuchPane(id))
1231 }
1232
1233 /// Phase-2 override of the trait's default `pane_snapshot`.
1234 /// Delegates to the inherent [`Self::pane_snapshot`] method,
1235 /// which reads the per-pane `PaneGrid` installed by
1236 /// [`Self::spawn_pty_for`].
1237 fn pane_snapshot(&self, id: PaneId) -> ControlResult<tear_types::PaneSnapshot> {
1238 InProcess::pane_snapshot(self, id)
1239 }
1240
1241 /// Override the trait default with the no-alloc lookup —
1242 /// mado's input loop calls this per keystroke, so the
1243 /// fast path matters here.
1244 fn pane_cursor_keys_mode(&self, id: PaneId) -> ControlResult<bool> {
1245 InProcess::pane_cursor_keys_mode(self, id)
1246 }
1247
1248 /// Phase-3.1 override — resize the underlying PTY (fires
1249 /// SIGWINCH at the child) AND resize the per-pane PaneGrid
1250 /// so subsequent snapshots reflect the new geometry.
1251 fn pane_resize_absolute(
1252 &self,
1253 id: PaneId,
1254 cols: u16,
1255 rows: u16,
1256 ) -> ControlResult<()> {
1257 use portable_pty::PtySize;
1258 let pty_size = PtySize {
1259 rows,
1260 cols,
1261 pixel_width: 0,
1262 pixel_height: 0,
1263 };
1264 // Resize the PTY (delivers SIGWINCH to child).
1265 {
1266 let ptys = self.ptys.lock();
1267 let pty = ptys.get(&id).ok_or(ControlError::NoSuchPane(id))?;
1268 pty.resize(pty_size)
1269 .map_err(|e| ControlError::Internal(anyhow::anyhow!(e)))?;
1270 }
1271 // Resize the parser-backed grid so subsequent snapshots
1272 // honour the new geometry.
1273 if let Some(grid) = self.grids.lock().get(&id).cloned() {
1274 grid.lock().resize(cols as usize, rows as usize);
1275 }
1276 Ok(())
1277 }
1278}
1279
1280/// Inherent helpers that aren't part of the [`MultiplexerControl`] trait
1281/// surface — internal reflow + geometry plumbing the trait methods call.
1282impl InProcess {
1283 /// Reflow a window's panes from its [`LayoutNode`] tree — the single
1284 /// geometry source. Computes every pane's rect against the window's
1285 /// cell size, writes the cell size/origin onto each flat pane record,
1286 /// and SIGWINCHes each PTY to match. `split_pane`/`kill_pane`/
1287 /// `resize_pane` all call this after mutating the tree, so what a
1288 /// window displays always matches its layout. Best-effort: a pane
1289 /// squeezed to zero cells (too-small window) or whose PTY hasn't
1290 /// spawned yet is skipped, never an error.
1291 fn apply_layout_geometry(&self, sid: SessionId, wid: WindowId) {
1292 // Phase 1 — under the registry lock, recompute rects + stamp them
1293 // onto the pane records. We collect the rect set to apply to PTYs
1294 // AFTER dropping the lock (pane_resize_absolute takes ptys/grids
1295 // locks; holding registry across that is the wedge class we avoid
1296 // elsewhere in this file).
1297 let rects = {
1298 let mut r = self.registry.write();
1299 let Some(s) = r.sessions.get_mut(&sid) else {
1300 return;
1301 };
1302 let rects = match s.windows.get(&wid) {
1303 Some(w) => w
1304 .layout
1305 .compute_rects(Rect::sized(w.size_cells.0, w.size_cells.1)),
1306 None => return,
1307 };
1308 for (pane, rect) in &rects {
1309 if let Some(p) = s.panes.get_mut(pane) {
1310 p.size_cells = (rect.w.max(1), rect.h.max(1));
1311 p.origin_cells = (rect.x, rect.y);
1312 }
1313 }
1314 rects
1315 };
1316 // Phase 2 — SIGWINCH each PTY to its new geometry.
1317 for (pane, rect) in rects {
1318 if rect.w == 0 || rect.h == 0 {
1319 continue;
1320 }
1321 let _ = self.pane_resize_absolute(pane, rect.w, rect.h);
1322 }
1323 }
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329
1330 /// How long a test waits for a REAL child shell to produce output.
1331 ///
1332 /// This is a **timeout, not a performance assertion**: every waiter
1333 /// below returns as soon as it sees what it wants, so a generous bound
1334 /// costs nothing on the passing path and only decides how long a
1335 /// genuinely-broken run takes to fail.
1336 ///
1337 /// It exists because the same wait was written with FOUR different
1338 /// numbers (2s ×4, 5s, 10s ×3), and the 2-second ones were flaky:
1339 /// measured 2026-07-31, `pty_env_includes_tear_session_pane_socket_vars`
1340 /// failed in a full parallel workspace run — the shell's echo was
1341 /// captured but the sentinel had not arrived — while passing 3/3 in
1342 /// isolation. Under a full run many PTY-spawning tests compete for the
1343 /// machine and a real `/bin/sh` misses a tight deadline.
1344 ///
1345 /// One name, one number. If a test genuinely needs a different bound it
1346 /// should say why at its own site rather than quietly picking another.
1347 const CHILD_OUTPUT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1348
1349 #[test]
1350 fn new_inproc_starts_empty() {
1351 let inproc = InProcess::new();
1352 let sessions = inproc.list_sessions().unwrap();
1353 assert!(sessions.is_empty());
1354 }
1355
1356 /// A diverged tree is refused rather than silently healed.
1357 ///
1358 /// ★ READ THIS BEFORE TRUSTING IT — it does NOT prove what its first
1359 /// draft claimed. `kill_pane`'s `LeafRemoval::NotFound` arm was split out
1360 /// from the ordinary `Removed` close (they shared an arm, so structural
1361 /// corruption was indistinguishable from a routine kill). This test was
1362 /// written to earn that split, and MEASURED VACUOUS against it: it passes
1363 /// identically with the old merged arm restored.
1364 ///
1365 /// The reason is `locate_pane` (registry.rs), which requires membership
1366 /// in BOTH `s.panes` and the window's tree and runs at inproc.rs's read
1367 /// lock *before* `remove_leaf` is ever called. So a diverged pane is
1368 /// rejected there, and the refusal this asserts comes from that guard.
1369 ///
1370 /// Consequence, stated honestly: **`LeafRemoval::NotFound` is unreachable
1371 /// from a single-threaded caller.** It is a guard on the TOCTOU window
1372 /// between the read lock that resolves the pane and the write lock that
1373 /// mutates the tree — real under concurrency, and not reachable by any
1374 /// test that does not race those two locks. The arm split is kept as
1375 /// defence in depth, and is graded accordingly: **only-mitigated, ceiling
1376 /// = no single-threaded test can reach it; earning a green here needs a
1377 /// concurrent test that interleaves the two lock acquisitions.**
1378 ///
1379 /// What this test DOES pin, and it is worth pinning: `locate_pane`'s
1380 /// both-memberships requirement is load-bearing, and a refactor that
1381 /// relaxed it to a single lookup would make this go red.
1382 #[test]
1383 fn kill_pane_refuses_when_tree_and_pane_map_have_diverged() {
1384 let inproc = InProcess::new();
1385 let sid = inproc.new_session("divergence", "/bin/sh").unwrap();
1386 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1387
1388 // Break the invariant behind the API's back: evict the pane from the
1389 // layout tree while leaving its record in `s.panes`, so `locate_pane`
1390 // still resolves it but `remove_leaf` cannot find it.
1391 {
1392 let mut r = inproc.registry.write();
1393 let s = r.sessions.get_mut(&sid).unwrap();
1394 let wid = *s.windows.keys().next().unwrap();
1395 let w = s.windows.get_mut(&wid).unwrap();
1396 w.layout = tear_types::LayoutNode::leaf(tear_types::PaneId::from_seed("decoy"));
1397 }
1398
1399 let err = inproc
1400 .kill_pane(pane)
1401 .expect_err("a diverged tree must be refused, not silently healed");
1402 assert!(
1403 matches!(err, ControlError::NoSuchPane(p) if p == pane),
1404 "expected NoSuchPane for the diverged pane, got {err:?}"
1405 );
1406 }
1407
1408 /// Forcing function: the TEAR_* env-var names tear stamps onto every
1409 /// spawned pane come from the typed cross-tool contract
1410 /// (`ishou_tokens::FleetStateVar`), which seki's prompt reads from the
1411 /// same source. Pinning the variant→name mapping here makes a rename
1412 /// on the producer side a compile+test failure on the single source of
1413 /// truth, so it can never silently drift from the consumer.
1414 #[test]
1415 fn pane_env_var_names_come_from_fleet_state_contract() {
1416 use ishou_tokens::FleetStateVar;
1417 assert_eq!(FleetStateVar::TearSessionId.name(), "TEAR_SESSION_ID");
1418 assert_eq!(FleetStateVar::TearSessionName.name(), "TEAR_SESSION_NAME");
1419 assert_eq!(FleetStateVar::TearPaneId.name(), "TEAR_PANE_ID");
1420 assert_eq!(FleetStateVar::TearSocket.name(), "TEAR_SOCKET");
1421 }
1422
1423 #[test]
1424 fn pty_env_path_includes_nix_profile_dirs() {
1425 // Reproduces the production bug where the launchd-spawned
1426 // tear-daemon inherited PATH = "/usr/bin:/bin:/usr/sbin:
1427 // /sbin" — every shell tear spawned then couldn't find
1428 // `tear` (or any home-manager binary), and starship's
1429 // [custom.tear] prompt block hung trying to invoke it.
1430 // The fix prepends /etc/profiles/per-user/$USER/bin +
1431 // ~/.nix-profile/bin + /run/current-system/sw/bin so
1432 // home-manager binaries resolve.
1433 let inproc = Arc::new(InProcess::new());
1434 let sid = inproc.new_session("path-test", "/bin/sh").unwrap();
1435 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1436
1437 let (tx, rx) = mpsc::channel::<Vec<u8>>();
1438 inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1439
1440 inproc.send_keys(pane, b"printf 'PATH=[%s]\\n' \"$PATH\"\n").expect("send_keys");
1441
1442 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1443 let mut buf = Vec::<u8>::new();
1444 while std::time::Instant::now() < deadline {
1445 if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1446 buf.extend_from_slice(&chunk);
1447 if let Ok(s) = std::str::from_utf8(&buf) {
1448 if s.contains("PATH=[") && s.contains("]\n") { break; }
1449 }
1450 }
1451 }
1452 let text = String::from_utf8_lossy(&buf);
1453 assert!(text.contains("PATH=["), "no PATH output: {text:?}");
1454 assert!(
1455 text.contains("/etc/profiles/per-user/")
1456 || text.contains("/.nix-profile/bin")
1457 || text.contains("/run/current-system/sw/bin"),
1458 "PATH missing Nix profile dirs — home-manager binaries (tear, starship, etc.) wouldn't resolve: {text:?}"
1459 );
1460 }
1461
1462 #[test]
1463 fn pty_env_provides_term_default_when_parent_lacks_it() {
1464 // Reproduces the production bug where launchd-spawned
1465 // tear-daemons had no TERM in their env, so every shell
1466 // they spawned reported "TERM environment variable not
1467 // set" and `clear`/arrow keys broke. We can't perfectly
1468 // simulate the launchd-clean env in-process, but we can
1469 // assert TERM is always non-empty in the spawned child.
1470 let inproc = Arc::new(InProcess::new());
1471 let sid = inproc.new_session("term-test", "/bin/sh").unwrap();
1472 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1473
1474 let (tx, rx) = mpsc::channel::<Vec<u8>>();
1475 inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1476
1477 inproc
1478 .send_keys(pane, b"printf 'TERM=[%s]\\n' \"${TERM:-MISSING}\"\n")
1479 .expect("send_keys");
1480
1481 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1482 let mut buf = Vec::<u8>::new();
1483 while std::time::Instant::now() < deadline {
1484 if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1485 buf.extend_from_slice(&chunk);
1486 if let Ok(s) = std::str::from_utf8(&buf) {
1487 if s.contains("TERM=[") && s.contains("]\n") {
1488 break;
1489 }
1490 }
1491 }
1492 }
1493 let text = String::from_utf8_lossy(&buf);
1494 assert!(text.contains("TERM=["), "no TERM output: {text:?}");
1495 assert!(
1496 !text.contains("TERM=[MISSING]"),
1497 "TERM unset in child shell — terminfo would fail: {text:?}"
1498 );
1499 }
1500
1501 #[test]
1502 fn pty_env_includes_tear_session_pane_socket_vars() {
1503 // Spawn a fresh shell that prints the TEAR_* env vars + a
1504 // sentinel string, then subscribe to its bytes and assert
1505 // we see the sentinel + the env values land. Proves the
1506 // spawn_pty_for env injection works end-to-end (the
1507 // shell's child PROCESS observes them).
1508 let inproc = Arc::new(InProcess::new());
1509 inproc.set_socket_path(std::path::PathBuf::from("/tmp/tear-test-env.sock"));
1510
1511 let sid = inproc
1512 .new_session("env-test", "/bin/sh")
1513 .expect("new_session");
1514 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1515
1516 let (tx, rx) = mpsc::channel::<Vec<u8>>();
1517 inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1518
1519 inproc
1520 .send_keys(
1521 pane,
1522 b"printf 'SENTINEL[T=%s][S=%s][P=%s][SOCK=%s]\\n' \"${TEAR}\" \"${TEAR_SESSION_NAME}\" \"${TEAR_PANE_ID}\" \"${TEAR_SOCKET}\"\n",
1523 )
1524 .expect("send_keys");
1525
1526 // Collect output for up to 2 seconds.
1527 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1528 let mut buf = Vec::<u8>::new();
1529 while std::time::Instant::now() < deadline {
1530 if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1531 buf.extend_from_slice(&chunk);
1532 if let Ok(s) = std::str::from_utf8(&buf) {
1533 if s.contains("SENTINEL[") && s.contains(']') {
1534 // Wait briefly for the rest of the line to arrive.
1535 std::thread::sleep(std::time::Duration::from_millis(50));
1536 while let Ok(more) = rx.try_recv() {
1537 buf.extend_from_slice(&more);
1538 }
1539 break;
1540 }
1541 }
1542 }
1543 }
1544 let text = String::from_utf8_lossy(&buf);
1545 assert!(text.contains("SENTINEL["), "no sentinel in output: {text:?}");
1546 assert!(text.contains("T=1"), "TEAR=1 not present: {text:?}");
1547 assert!(text.contains("S=env-test"), "TEAR_SESSION_NAME wrong: {text:?}");
1548 assert!(text.contains("SOCK=/tmp/tear-test-env.sock"), "TEAR_SOCKET wrong: {text:?}");
1549 }
1550
1551 /// **`SpawnEnv` override reaches the child + wins over the
1552 /// fallback** (operator report 2026-06-12: vim grey + wrong font in
1553 /// the embedded-tear window came from the embedded path stamping only
1554 /// xterm-256color). An embedder (mado) sets a `SpawnEnv` whose
1555 /// `TERM` override + `COLORTERM` must land on the child's env,
1556 /// overriding the conservative fallback `spawn_pty_for` would
1557 /// otherwise stamp. PTY-gated (openpty); passes in isolation.
1558 #[test]
1559 fn spawn_env_override_reaches_child_and_wins_over_fallback() {
1560 let inproc = Arc::new(InProcess::new());
1561 // The embedder's capability projection: a richer TERM than the
1562 // xterm-256color fallback + the truecolor signal vim needs.
1563 inproc.set_spawn_env(tear_types::SpawnEnv::from_overrides(vec![
1564 ("TERM".into(), "xterm-ghostty".into()),
1565 ("COLORTERM".into(), "truecolor".into()),
1566 ]));
1567 let sid = inproc.new_session("spawnenv-test", "/bin/sh").unwrap();
1568 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1569
1570 let (tx, rx) = mpsc::channel::<Vec<u8>>();
1571 inproc.subscribers.lock().entry(pane).or_default().senders.push(tx);
1572 inproc
1573 .send_keys(
1574 pane,
1575 b"printf 'SENV[T=%s][C=%s]\\n' \"${TERM}\" \"${COLORTERM}\"\n",
1576 )
1577 .expect("send_keys");
1578
1579 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1580 let mut buf = Vec::<u8>::new();
1581 while std::time::Instant::now() < deadline {
1582 if let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(100)) {
1583 buf.extend_from_slice(&chunk);
1584 if let Ok(s) = std::str::from_utf8(&buf) {
1585 if s.contains("SENV[") && s.contains(']') {
1586 std::thread::sleep(std::time::Duration::from_millis(50));
1587 while let Ok(more) = rx.try_recv() {
1588 buf.extend_from_slice(&more);
1589 }
1590 break;
1591 }
1592 }
1593 }
1594 }
1595 let text = String::from_utf8_lossy(&buf);
1596 assert!(text.contains("SENV["), "no sentinel in output: {text:?}");
1597 assert!(
1598 text.contains("T=xterm-ghostty"),
1599 "embedder TERM override did not reach the child (fallback won): {text:?}"
1600 );
1601 assert!(
1602 text.contains("C=truecolor"),
1603 "embedder COLORTERM override did not reach the child: {text:?}"
1604 );
1605 }
1606
1607 #[test]
1608 fn get_nonexistent_session_errors() {
1609 let inproc = InProcess::new();
1610 let err = inproc.get_session(SessionId(99)).unwrap_err();
1611 assert!(matches!(err, ControlError::NoSuchSession(_)));
1612 }
1613
1614 #[test]
1615 fn subscribe_pane_bytes_on_nonexistent_pane_returns_nosuch() {
1616 let inproc = InProcess::new();
1617 let pane = PaneId::from_seed("phantom");
1618 let err = inproc.subscribe_pane_bytes(pane).unwrap_err();
1619 assert!(matches!(err, ControlError::NoSuchPane(p) if p == pane));
1620 }
1621
1622 #[test]
1623 fn child_exit_disconnects_subscribers_and_marks_pane_exited() {
1624 // Regression (mado embedded-tear "typing `exit` does nothing"):
1625 // when the shell exits, the per-pane byte channel MUST
1626 // disconnect so a single-pane GUI learns the child is gone and
1627 // can close its window. Before the fix the PTY reader thread
1628 // just ended on EOF, leaving every engate/daemon Receiver
1629 // blocked forever on a phantom-Running pane whose senders were
1630 // never dropped.
1631 let inproc = Arc::new(InProcess::new());
1632 let sid = inproc
1633 .new_session("exit-test", "/bin/sh")
1634 .expect("new_session");
1635 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1636
1637 // Subscribe while the pane is alive (mirrors mado's attach).
1638 let rx = inproc.subscribe_pane_bytes(pane).expect("subscribe");
1639
1640 // Drive the shell to exit with a specific code.
1641 inproc.send_keys(pane, b"exit 7\n").expect("send_keys");
1642
1643 // The receiver must eventually disconnect — that Err is the
1644 // end-of-stream signal engate's run() / the daemon's
1645 // serve_subscription block on.
1646 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1647 let mut disconnected = false;
1648 while std::time::Instant::now() < deadline {
1649 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1650 Ok(_) => continue, // drain echoed input / shell output
1651 Err(mpsc::RecvTimeoutError::Timeout) => continue,
1652 Err(mpsc::RecvTimeoutError::Disconnected) => {
1653 disconnected = true;
1654 break;
1655 }
1656 }
1657 }
1658 assert!(
1659 disconnected,
1660 "subscriber channel never disconnected after `exit` — a single-pane GUI would hang open"
1661 );
1662
1663 // The pane must be modeled as Exited (remain-on-exit) with the
1664 // child's real exit code propagated.
1665 let session = inproc.get_session(sid).unwrap();
1666 let state = session.panes.get(&pane).map(|p| p.state);
1667 assert_eq!(
1668 state,
1669 Some(tear_types::PaneState::Exited { code: 7 }),
1670 "pane should be Exited{{ code: 7 }}, got {state:?}"
1671 );
1672
1673 // A subscribe AFTER exit must return an already-disconnected
1674 // receiver, never a live registration that would block forever.
1675 let rx2 = inproc
1676 .subscribe_pane_bytes(pane)
1677 .expect("subscribe after exit still resolves (remain-on-exit pane)");
1678 assert!(
1679 matches!(rx2.recv(), Err(mpsc::RecvError)),
1680 "post-exit subscribe must yield an immediately-disconnected receiver"
1681 );
1682 }
1683
1684 #[test]
1685 fn fully_exited_unwatched_session_is_reaped() {
1686 // Session-leak fix (2026-07-06): a session whose shell exits
1687 // with NO subscriber ever attached (agent spawn-and-walk-away,
1688 // one-shot command) must leave the registry entirely — before
1689 // the fix it lingered forever as a ghost row in mado's Ctrl-S
1690 // picker.
1691 let inproc = Arc::new(InProcess::new());
1692 let sid = inproc
1693 .new_session("reap-test", "/bin/sh")
1694 .expect("new_session");
1695 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1696
1697 // No subscriber attaches. Exit the shell.
1698 inproc.send_keys(pane, b"exit\n").expect("send_keys");
1699
1700 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1701 while std::time::Instant::now() < deadline {
1702 if inproc.list_sessions().unwrap().is_empty() {
1703 break;
1704 }
1705 std::thread::sleep(std::time::Duration::from_millis(50));
1706 }
1707 assert!(
1708 inproc.list_sessions().unwrap().is_empty(),
1709 "fully-exited unwatched session was not reaped from the registry"
1710 );
1711 // Runtime artifacts must be gone too — a reaped session that
1712 // strands a PtyHandle/grid/subscriber entry is a slow leak.
1713 assert!(inproc.ptys.lock().is_empty(), "reap left a PtyHandle behind");
1714 assert!(inproc.grids.lock().is_empty(), "reap left a grid behind");
1715 assert!(
1716 inproc.subscribers.lock().is_empty(),
1717 "reap left a subscriber entry behind"
1718 );
1719 }
1720
1721 #[test]
1722 fn watched_session_survives_exit_with_remain_on_exit() {
1723 // The conservative half of the reap: a session SOMEONE was
1724 // watching (live sender at exit time — a mado window, a
1725 // recorder) keeps tmux remain-on-exit semantics: pane marked
1726 // Exited, final grid inspectable, session still listed.
1727 let inproc = Arc::new(InProcess::new());
1728 let sid = inproc
1729 .new_session("remain-test", "/bin/sh")
1730 .expect("new_session");
1731 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1732
1733 let rx = inproc.subscribe_pane_bytes(pane).expect("subscribe");
1734 inproc.send_keys(pane, b"exit\n").expect("send_keys");
1735
1736 // Wait for the end-of-stream disconnect (fires after the exit
1737 // handler ran its reap decision with `watched = true`).
1738 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1739 let mut disconnected = false;
1740 while std::time::Instant::now() < deadline {
1741 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1742 Ok(_) => continue,
1743 Err(mpsc::RecvTimeoutError::Timeout) => continue,
1744 Err(mpsc::RecvTimeoutError::Disconnected) => {
1745 disconnected = true;
1746 break;
1747 }
1748 }
1749 }
1750 assert!(disconnected, "subscriber never disconnected after exit");
1751
1752 let sessions = inproc.list_sessions().unwrap();
1753 assert_eq!(
1754 sessions.len(),
1755 1,
1756 "watched session must remain-on-exit, not be reaped"
1757 );
1758 let state = sessions[0].panes.get(&pane).map(|p| p.state);
1759 assert!(
1760 matches!(state, Some(tear_types::PaneState::Exited { .. })),
1761 "pane should be Exited, got {state:?}"
1762 );
1763 }
1764
1765 #[test]
1766 fn pane_snapshot_on_nonexistent_pane_returns_nosuch() {
1767 let inproc = InProcess::new();
1768 let pane = PaneId::from_seed("phantom");
1769 let err = inproc.pane_snapshot(pane).unwrap_err();
1770 assert!(matches!(err, ControlError::NoSuchPane(p) if p == pane));
1771 }
1772
1773 #[test]
1774 fn pane_resize_absolute_on_nonexistent_pane_returns_nosuch() {
1775 let inproc = InProcess::new();
1776 let pane = PaneId::from_seed("phantom");
1777 let err = inproc.pane_resize_absolute(pane, 80, 24).unwrap_err();
1778 assert!(matches!(err, ControlError::NoSuchPane(p) if p == pane));
1779 }
1780
1781 #[test]
1782 fn rename_session_to_same_name_is_idempotent() {
1783 let inproc = InProcess::new();
1784 let sid = inproc.new_session("work", "/bin/sh").unwrap();
1785 // Rename to current name should succeed without error.
1786 inproc.rename_session(sid, "work").unwrap();
1787 let s = inproc.get_session(sid).unwrap();
1788 assert_eq!(s.name, "work");
1789 // Now rename to a different name.
1790 inproc.rename_session(sid, "play").unwrap();
1791 let s2 = inproc.get_session(sid).unwrap();
1792 assert_eq!(s2.name, "play");
1793 }
1794
1795 #[test]
1796 fn kill_session_with_active_subscriber_returns_promptly() {
1797 // Regression (mado L1 teardown wedge, 2026-06-10): kill_session
1798 // used to drop PtyHandles INSIDE the ptys/grids/subscribers
1799 // lock scope; PtyHandle::Drop then block-waited on the child
1800 // while the pane's reader thread sat blocked acquiring the
1801 // same subscribers lock from on_bytes — mutual wait, observed
1802 // as a 20+ minute wedge. Post-fix the handles leave the maps
1803 // under the locks but die outside them, and the reap itself is
1804 // bounded (pty::reap_with_deadline).
1805 //
1806 // /bin/cat echoes everything, so a feeder thread keeps the
1807 // reader thread hot (contending the subscribers lock) while
1808 // another thread kills the session. The watchdog channel turns
1809 // a re-introduced deadlock into a <5s test failure instead of
1810 // a hung suite.
1811 let inproc = Arc::new(InProcess::new());
1812 let sid = inproc
1813 .new_session("kill-fast", "/bin/cat")
1814 .expect("new_session");
1815 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1816
1817 // Active subscriber (mirrors mado's attach_live).
1818 let rx = inproc.subscribe_pane_bytes(pane).expect("subscribe");
1819
1820 // Feeder — keeps bytes flowing through on_bytes so the reader
1821 // thread is actively taking the subscribers lock during kill.
1822 let feeder_inproc = Arc::clone(&inproc);
1823 let feeding = Arc::new(std::sync::atomic::AtomicBool::new(true));
1824 let feeding_for_thread = Arc::clone(&feeding);
1825 let feeder = std::thread::spawn(move || {
1826 let chunk = vec![b'x'; 4096];
1827 while feeding_for_thread.load(std::sync::atomic::Ordering::Relaxed) {
1828 if feeder_inproc.send_keys(pane, &chunk).is_err() {
1829 break; // pane gone — the kill landed
1830 }
1831 }
1832 });
1833 // Drain one echo so we know the reader thread is live.
1834 let _ = rx.recv_timeout(std::time::Duration::from_secs(2));
1835
1836 // kill_session on a helper thread + watchdog recv.
1837 let killer_inproc = Arc::clone(&inproc);
1838 let (done_tx, done_rx) = mpsc::channel();
1839 std::thread::spawn(move || {
1840 let started = std::time::Instant::now();
1841 let result = killer_inproc.kill_session(sid);
1842 let _ = done_tx.send((result, started.elapsed()));
1843 });
1844 let (result, elapsed) = done_rx
1845 .recv_timeout(std::time::Duration::from_secs(5))
1846 .expect("kill_session deadlocked — did not return within 5s");
1847 feeding.store(false, std::sync::atomic::Ordering::Relaxed);
1848 result.expect("kill_session errored");
1849 assert!(
1850 elapsed < std::time::Duration::from_secs(5),
1851 "kill_session took {elapsed:?} with an active subscriber"
1852 );
1853 let _ = feeder.join();
1854
1855 // The subscriber must observe end-of-stream (senders dropped by
1856 // detach_panes), never block forever on a dead pane.
1857 let deadline = std::time::Instant::now() + CHILD_OUTPUT_TIMEOUT;
1858 let mut disconnected = false;
1859 while std::time::Instant::now() < deadline {
1860 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1861 Ok(_) => continue, // drain buffered echo
1862 Err(mpsc::RecvTimeoutError::Timeout) => continue,
1863 Err(mpsc::RecvTimeoutError::Disconnected) => {
1864 disconnected = true;
1865 break;
1866 }
1867 }
1868 }
1869 assert!(
1870 disconnected,
1871 "subscriber channel never disconnected after kill_session"
1872 );
1873 }
1874
1875 #[test]
1876 fn new_session_then_kill_then_subscribe_returns_nosuch() {
1877 // The kill-session path must prune subscribers + grids + ptys
1878 // so a later subscribe to the dead pane errors cleanly.
1879 let inproc = InProcess::new();
1880 let sid = inproc.new_session("temp", "/bin/sh").unwrap();
1881 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1882 inproc.kill_session(sid).unwrap();
1883 let err = inproc.subscribe_pane_bytes(pane).unwrap_err();
1884 assert!(matches!(err, ControlError::NoSuchPane(_)));
1885 }
1886
1887 #[test]
1888 fn kill_session_prunes_the_recording_buffer() {
1889 // The `recordings` map had NO `.remove()` anywhere in the crate, so
1890 // every recorded pane kept its whole captured byte buffer for the
1891 // DAEMON'S LIFETIME — invisible to `tear list`, `pane_stats` and
1892 // `daemon_status`'s pane counts, which all read other maps. A few
1893 // recorded build logs is routinely hundreds of MB.
1894 //
1895 // Sibling of `new_session_then_kill_then_subscribe_returns_nosuch`,
1896 // which pins the same teardown contract for subscribers + grids +
1897 // ptys. Recordings were simply missing from that list.
1898 let inproc = InProcess::new();
1899 let sid = inproc.new_session("rec-leak", "/bin/sh").unwrap();
1900 let pane = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1901 inproc.enable_pane_recording(pane).unwrap();
1902 assert_eq!(
1903 inproc.recordings.lock().len(),
1904 1,
1905 "precondition: the recording must actually be registered, or this \
1906 test would pass vacuously against an empty map"
1907 );
1908 inproc.kill_session(sid).unwrap();
1909 assert!(
1910 inproc.recordings.lock().is_empty(),
1911 "kill_session must drop the pane's recording buffer; leaking it \
1912 retains the captured bytes until the daemon exits"
1913 );
1914 }
1915
1916 // ── #2 input policy ────────────────────────────────────────
1917
1918 #[test]
1919 fn send_keys_rejected_when_pane_locked() {
1920 let inproc = InProcess::new();
1921 let sid = inproc.new_session("policy", "/bin/sh").unwrap();
1922 let session = inproc.get_session(sid).unwrap();
1923 let pane_id = *session.panes.keys().next().unwrap();
1924
1925 // Lock it.
1926 inproc
1927 .set_input_policy(pane_id, tear_types::InputPolicy::Locked)
1928 .unwrap();
1929 // send_keys must error with Rejected.
1930 let err = inproc.send_keys(pane_id, b"x").unwrap_err();
1931 assert!(
1932 matches!(err, tear_types::ControlError::Rejected(_)),
1933 "expected Rejected, got {err:?}"
1934 );
1935
1936 // Unlock — send_keys works again.
1937 inproc
1938 .set_input_policy(pane_id, tear_types::InputPolicy::Free)
1939 .unwrap();
1940 inproc.send_keys(pane_id, b"y").unwrap();
1941 }
1942
1943 #[test]
1944 fn set_input_policy_on_nonexistent_pane_returns_nosuch() {
1945 let inproc = InProcess::new();
1946 let err = inproc
1947 .set_input_policy(tear_types::PaneId(0xdead_beef), tear_types::InputPolicy::Locked)
1948 .unwrap_err();
1949 assert!(matches!(err, tear_types::ControlError::NoSuchPane(_)));
1950 }
1951
1952 #[test]
1953 fn set_input_policy_is_idempotent() {
1954 let inproc = InProcess::new();
1955 let sid = inproc.new_session("idem", "/bin/sh").unwrap();
1956 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1957 inproc.set_input_policy(pane_id, tear_types::InputPolicy::Locked).unwrap();
1958 inproc.set_input_policy(pane_id, tear_types::InputPolicy::Locked).unwrap();
1959 inproc.set_input_policy(pane_id, tear_types::InputPolicy::Free).unwrap();
1960 inproc.set_input_policy(pane_id, tear_types::InputPolicy::Free).unwrap();
1961 // No assertion needed — the test is that none of these panic
1962 // or return Err on duplicate state.
1963 }
1964
1965 #[test]
1966 fn send_keys_treats_leader_as_free_at_inproc_layer() {
1967 // In-process consumers have no per-client identity at the
1968 // trait surface, so Leader collapses to Free here — the
1969 // daemon adds the identity-gating layer on top via
1970 // serve_connection_with_auth. This test pins the semantic
1971 // so future refactors don't accidentally start rejecting.
1972 let inproc = InProcess::new();
1973 let sid = inproc.new_session("leader-inproc", "/bin/sh").unwrap();
1974 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1975 inproc
1976 .set_input_policy(pane_id, tear_types::InputPolicy::leader(7))
1977 .unwrap();
1978 // No error — InProcess::send_keys does not enforce Leader.
1979 inproc.send_keys(pane_id, b"x").unwrap();
1980 }
1981
1982 #[test]
1983 fn send_keys_unaffected_when_policy_remains_free() {
1984 // Default policy is Free; send_keys should accept right away
1985 // without the operator touching the policy. Smoke-checks the
1986 // policy gate's no-op path.
1987 let inproc = InProcess::new();
1988 let sid = inproc.new_session("free-default", "/bin/sh").unwrap();
1989 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
1990 inproc.send_keys(pane_id, b"hello").unwrap();
1991 }
1992
1993 #[test]
1994 fn send_keys_after_unlock_round_trip() {
1995 // Locked → Free → Locked → Free. Each Free interval must
1996 // accept input.
1997 let inproc = InProcess::new();
1998 let sid = inproc.new_session("rt", "/bin/sh").unwrap();
1999 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2000 for round in 0..2 {
2001 inproc
2002 .set_input_policy(pane_id, tear_types::InputPolicy::Locked)
2003 .unwrap();
2004 assert!(
2005 inproc.send_keys(pane_id, b"x").is_err(),
2006 "round {round}: Locked accepted send_keys"
2007 );
2008 inproc
2009 .set_input_policy(pane_id, tear_types::InputPolicy::Free)
2010 .unwrap();
2011 inproc
2012 .send_keys(pane_id, b"y")
2013 .unwrap_or_else(|e| panic!("round {round}: Free rejected send_keys: {e:?}"));
2014 }
2015 }
2016
2017 // ── Pane-as-block (OSC 133) ────────────────────────────
2018
2019 #[test]
2020 fn pane_blocks_captures_osc_133_round_trip() {
2021 let inproc = InProcess::new();
2022 let sid = inproc.new_session("blocks-test", "/bin/sh").unwrap();
2023 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2024
2025 // Drive a full OSC 133 cycle through the PTY by sending
2026 // the bytes via send_keys. The shell's echo back loops
2027 // them through PaneGrid → block extractor.
2028 //
2029 // We use raw escape bytes: ESC ] 133 ; X BEL
2030 //
2031 // bash + readline echo the bytes back to the PTY only on
2032 // INPUT, not on output. The cleanest test path: write
2033 // the OSC 133 sequence directly into the pty's slave
2034 // side via send_keys, then drain.
2035 //
2036 // For the unit test we bypass the PTY shell and feed the
2037 // grid directly via a synthetic call. The block
2038 // extractor is fully covered by tear-core/src/blocks.rs
2039 // unit tests; here we verify the wiring through
2040 // pane_blocks_list returns what we'd expect when blocks
2041 // exist.
2042 let grid_arc = {
2043 let map = inproc.grids.lock();
2044 map.get(&pane_id).cloned().unwrap()
2045 };
2046 {
2047 let mut grid = grid_arc.lock();
2048 grid.feed(b"\x1b]133;A\x07");
2049 grid.feed(b"$ ");
2050 grid.feed(b"\x1b]133;B\x07");
2051 grid.feed(b"echo hi");
2052 grid.feed(b"\x1b]133;C\x07");
2053 grid.feed(b"hi\r\n");
2054 grid.feed(b"\x1b]133;D;0\x07");
2055 }
2056
2057 let blocks = inproc.pane_blocks_list(pane_id, 0, 10).unwrap();
2058 assert_eq!(blocks.len(), 1);
2059 let b = &blocks[0];
2060 assert_eq!(b.prompt, "$ ");
2061 assert_eq!(b.command, "echo hi");
2062 assert!(b.output.contains("hi"));
2063 assert_eq!(b.exit_code, Some(0));
2064
2065 let (total, in_progress) = inproc.pane_blocks_status(pane_id).unwrap();
2066 assert_eq!(total, 1);
2067 assert!(!in_progress);
2068
2069 let one = inproc.pane_block_at(pane_id, 0).unwrap();
2070 assert_eq!(one.index, 0);
2071 }
2072
2073 /// END-TO-END attribution: a session an AGENT spawned produces blocks
2074 /// that say so, all the way out through the same `pane_blocks_list`
2075 /// an operator or an MCP client reads.
2076 ///
2077 /// This is the test that matters. `blocks.rs` already proves the
2078 /// extractor stamps what it is told; this proves the daemon actually
2079 /// TELLS it — the wiring, not the mechanism. Without this, a
2080 /// `stamp_yurai` nobody calls would keep every unit test green while
2081 /// every real block read `Unknown`, which is precisely the
2082 /// declared-but-unwired failure this repo has been bitten by before.
2083 #[test]
2084 fn an_agent_spawned_session_produces_attributed_blocks() {
2085 let inproc = InProcess::new();
2086 let sid = inproc
2087 .new_session_yurai(
2088 "attributed",
2089 "/bin/sh",
2090 &[],
2091 tear_types::SessionSource::Agent,
2092 (80, 24),
2093 tear_types::Yurai::Automation {
2094 label: Some("claude-code".into()),
2095 },
2096 )
2097 .unwrap();
2098 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2099
2100 let grid_arc = {
2101 let map = inproc.grids.lock();
2102 map.get(&pane_id).cloned().unwrap()
2103 };
2104 {
2105 let mut grid = grid_arc.lock();
2106 grid.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07rm -rf /tmp/x\x1b]133;C\x07\x1b]133;D;0\x07");
2107 }
2108
2109 let blocks = inproc.pane_blocks_list(pane_id, 0, 10).unwrap();
2110 assert_eq!(blocks.len(), 1);
2111 assert_eq!(
2112 blocks[0].yurai,
2113 tear_types::Yurai::Automation {
2114 label: Some("claude-code".into())
2115 },
2116 "an agent-spawned pane's blocks must carry the agent's label — \
2117 without it this command is indistinguishable from the operator's"
2118 );
2119 }
2120
2121 /// The companion honesty case: a session spawned with no declared
2122 /// provenance stays `Unknown`, never silently upgraded to `Human`.
2123 #[test]
2124 fn an_undeclared_session_produces_unknown_blocks_not_human() {
2125 let inproc = InProcess::new();
2126 let sid = inproc.new_session("undeclared", "/bin/sh").unwrap();
2127 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2128 let grid_arc = {
2129 let map = inproc.grids.lock();
2130 map.get(&pane_id).cloned().unwrap()
2131 };
2132 {
2133 let mut grid = grid_arc.lock();
2134 grid.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07ls\x1b]133;C\x07\x1b]133;D;0\x07");
2135 }
2136 let blocks = inproc.pane_blocks_list(pane_id, 0, 10).unwrap();
2137 assert_eq!(blocks.len(), 1);
2138 assert_eq!(
2139 blocks[0].yurai,
2140 tear_types::Yurai::Unknown,
2141 "unknown must stay unknown — a silent upgrade to Human would \
2142 launder exactly the commands worth attributing"
2143 );
2144 }
2145
2146 #[test]
2147 fn pane_block_at_on_missing_index_returns_rejected() {
2148 let inproc = InProcess::new();
2149 let sid = inproc.new_session("missing-block", "/bin/sh").unwrap();
2150 let pane_id = *inproc.get_session(sid).unwrap().panes.keys().next().unwrap();
2151 let err = inproc.pane_block_at(pane_id, 99).unwrap_err();
2152 assert!(matches!(err, ControlError::Rejected(_)));
2153 }
2154
2155 #[test]
2156 fn pane_blocks_on_nonexistent_pane_returns_nosuch() {
2157 let inproc = InProcess::new();
2158 let err = inproc
2159 .pane_blocks_list(PaneId(0xdead_beef), 0, 10)
2160 .unwrap_err();
2161 assert!(matches!(err, ControlError::NoSuchPane(_)));
2162 }
2163
2164 #[test]
2165 fn list_sessions_returns_all_created_sessions() {
2166 let inproc = InProcess::new();
2167 let a = inproc.new_session("alpha", "/bin/sh").unwrap();
2168 let b = inproc.new_session("beta", "/bin/sh").unwrap();
2169 let c = inproc.new_session("gamma", "/bin/sh").unwrap();
2170 let sessions = inproc.list_sessions().unwrap();
2171 assert_eq!(sessions.len(), 3);
2172 let mut names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
2173 names.sort();
2174 assert_eq!(names, vec!["alpha", "beta", "gamma"]);
2175 let ids: std::collections::HashSet<_> =
2176 sessions.iter().map(|s| s.id).collect();
2177 for id in [a, b, c] {
2178 assert!(ids.contains(&id));
2179 }
2180 // Note: sessions_in_order sorts by created_at_unix (second
2181 // precision); same-second creates are ordered by BTreeMap key
2182 // (BLAKE3-derived SessionId). Tests asserting strict
2183 // insertion order would be flaky.
2184 }
2185
2186 /// The active pane of a session's active window — the usual split
2187 /// origin.
2188 fn active_pane(inproc: &InProcess, sid: SessionId) -> PaneId {
2189 let s = inproc.get_session(sid).unwrap();
2190 s.windows[&s.active_window].active_pane
2191 }
2192
2193 #[test]
2194 fn split_pane_targets_the_origin_leaf_and_validates() {
2195 let inproc = InProcess::new();
2196 let sid = inproc.new_session("split", "/bin/sh").unwrap();
2197 let origin = active_pane(&inproc, sid);
2198 let new = inproc.split_pane(origin, Direction::Right, "/bin/sh", &[]).unwrap();
2199
2200 let s = inproc.get_session(sid).unwrap();
2201 let w = &s.windows[&s.active_window];
2202 // The tree now holds exactly two leaves, origin then new (Right
2203 // puts the new pane after the origin).
2204 assert_eq!(w.layout.pane_count(), 2);
2205 assert_eq!(w.layout.panes(), vec![origin, new]);
2206 assert_eq!(w.active_pane, new);
2207 assert!(s.panes.contains_key(&origin) && s.panes.contains_key(&new));
2208 // Structurally sound — no NULL leaf, no aliasing, ratio in range.
2209 w.layout.validate().unwrap();
2210 }
2211
2212 #[test]
2213 fn kill_pane_collapses_tree_without_dangling_leaf() {
2214 let inproc = InProcess::new();
2215 let sid = inproc.new_session("kill", "/bin/sh").unwrap();
2216 let origin = active_pane(&inproc, sid);
2217 let new = inproc.split_pane(origin, Direction::Below, "/bin/sh", &[]).unwrap();
2218
2219 inproc.kill_pane(new).unwrap();
2220 let s = inproc.get_session(sid).unwrap();
2221 let w = &s.windows[&s.active_window];
2222 // The split collapsed back into the surviving origin leaf — no
2223 // PaneId::NULL hole, validate() proves it.
2224 assert_eq!(w.layout, tear_types::LayoutNode::leaf(origin));
2225 assert_eq!(w.active_pane, origin);
2226 assert!(!s.panes.contains_key(&new));
2227 w.layout.validate().unwrap();
2228 }
2229
2230 #[test]
2231 fn killing_active_windows_last_pane_retargets_active_window() {
2232 // Regression: kill_pane's WasRoot arm removed the window but left
2233 // session.active_window pointing at the removed id, panicking the
2234 // s.windows[&active_window] index sites. A second window must
2235 // inherit focus when the active window's last pane is killed.
2236 let inproc = InProcess::new();
2237 let sid = inproc.new_session("multi", "/bin/sh").unwrap();
2238 let w1 = inproc.get_session(sid).unwrap().active_window;
2239 // A second window — and make it the active one we then kill.
2240 let w2 = inproc.new_window(sid, "second", "/bin/sh", &[]).unwrap();
2241 inproc.select_window(w2).unwrap();
2242 let only_pane_w2 = {
2243 let s = inproc.get_session(sid).unwrap();
2244 s.windows[&w2].active_pane
2245 };
2246 inproc.kill_pane(only_pane_w2).unwrap();
2247 let s = inproc.get_session(sid).unwrap();
2248 // w2 gone, focus fell back to the surviving window — and crucially
2249 // active_window still indexes a live window (no panic).
2250 assert!(!s.windows.contains_key(&w2));
2251 assert_eq!(s.active_window, w1);
2252 let _ = &s.windows[&s.active_window]; // must not panic
2253 }
2254
2255 #[test]
2256 fn kill_last_pane_closes_the_window() {
2257 let inproc = InProcess::new();
2258 let sid = inproc.new_session("solo", "/bin/sh").unwrap();
2259 let only = active_pane(&inproc, sid);
2260 // Killing the window's only pane closes the window (tmux
2261 // semantics) — nothing the layout tree can represent remains.
2262 inproc.kill_pane(only).unwrap();
2263 let s = inproc.get_session(sid).unwrap();
2264 assert!(s.windows.is_empty());
2265 assert!(!s.panes.contains_key(&only));
2266 }
2267
2268 #[test]
2269 fn resize_pane_shifts_the_governing_divider() {
2270 let inproc = InProcess::new();
2271 let sid = inproc.new_session("resize", "/bin/sh").unwrap();
2272 let origin = active_pane(&inproc, sid);
2273 let _new = inproc.split_pane(origin, Direction::Right, "/bin/sh", &[]).unwrap();
2274
2275 let w_before = {
2276 let s = inproc.get_session(sid).unwrap();
2277 s.windows[&s.active_window].size_cells.0
2278 };
2279 let origin_w_before = inproc.get_session(sid).unwrap().panes[&origin].size_cells.0;
2280 // Grow the (left) origin pane rightward by a quarter of the window.
2281 inproc
2282 .resize_pane(origin, Direction::Right, (w_before / 4) as i16)
2283 .unwrap();
2284 let origin_w_after = inproc.get_session(sid).unwrap().panes[&origin].size_cells.0;
2285 assert!(
2286 origin_w_after > origin_w_before,
2287 "origin pane should have widened: {origin_w_after} !> {origin_w_before}"
2288 );
2289 }
2290
2291 #[test]
2292 fn apply_layout_rearranges_existing_panes_into_a_named_preset() {
2293 let inproc = InProcess::new();
2294 let sid = inproc.new_session("layout", "/bin/sh").unwrap();
2295 let s0 = inproc.get_session(sid).unwrap();
2296 let wid = s0.active_window;
2297 let p0 = s0.windows[&wid].active_pane;
2298 // Build an arbitrary 3-pane shape: p0 | (p1 / p2).
2299 let p1 = inproc.split_pane(p0, Direction::Right, "/bin/sh", &[]).unwrap();
2300 let _p2 = inproc.split_pane(p1, Direction::Below, "/bin/sh", &[]).unwrap();
2301 let before: std::collections::BTreeSet<PaneId> =
2302 inproc.get_window(wid).unwrap().1.layout.panes().into_iter().collect();
2303
2304 // Re-tile into an even horizontal row — panes keep their PTYs.
2305 inproc.apply_layout(wid, LayoutKind::EvenHorizontal).unwrap();
2306
2307 let w = inproc.get_window(wid).unwrap().1;
2308 let after: std::collections::BTreeSet<PaneId> =
2309 w.layout.panes().into_iter().collect();
2310 assert_eq!(before, after, "the same panes are preserved (PTYs kept)");
2311 w.layout.validate().unwrap();
2312 // EvenHorizontal of 3 = three equal thirds (the even-ratio refinement).
2313 let rects = w.layout.compute_rects(Rect::sized(90, 24));
2314 let mut widths: Vec<u16> = rects.iter().map(|(_, r)| r.w).collect();
2315 widths.sort_unstable();
2316 assert_eq!(widths, vec![30, 30, 30]);
2317 }
2318
2319 #[test]
2320 fn apply_layout_custom_leaves_the_tree_untouched() {
2321 let inproc = InProcess::new();
2322 let sid = inproc.new_session("custom", "/bin/sh").unwrap();
2323 let wid = inproc.get_session(sid).unwrap().active_window;
2324 let p0 = inproc.get_session(sid).unwrap().windows[&wid].active_pane;
2325 inproc.split_pane(p0, Direction::Right, "/bin/sh", &[]).unwrap();
2326 let before = inproc.get_window(wid).unwrap().1.layout.clone();
2327 // Custom has no canonical arrangement → no-op.
2328 inproc.apply_layout(wid, LayoutKind::Custom).unwrap();
2329 assert_eq!(inproc.get_window(wid).unwrap().1.layout, before);
2330 }
2331
2332 #[test]
2333 fn apply_layout_unknown_window_is_no_such_window() {
2334 let inproc = InProcess::new();
2335 let err = inproc
2336 .apply_layout(WindowId::from_seed("ghost"), LayoutKind::Tiled)
2337 .unwrap_err();
2338 assert!(matches!(err, ControlError::NoSuchWindow(_)));
2339 }
2340}
2341
2342impl InProcess {
2343 /// Engage or release the operator's brake — see [`tear_types::freio`].
2344 ///
2345 /// `session: None` means every session; that is the one-gesture panic
2346 /// path. Returns, in order: every session's state after the call, the
2347 /// panes actually braked, and — critically — the panes the brake could
2348 /// NOT reach because their provenance is unknown.
2349 ///
2350 /// That third list is not diagnostics. An operator who pressed a panic
2351 /// button must be told what it did not stop, or they will believe
2352 /// everything halted.
2353 ///
2354 /// `at_unix` is stamped HERE, from the daemon's clock. No caller
2355 /// supplies it, so a backdated brake has no code path.
2356 pub fn set_freio(
2357 &self,
2358 session: Option<SessionId>,
2359 engaged: bool,
2360 ) -> (Vec<(SessionId, tear_types::Freio)>, Vec<PaneId>, Vec<PaneId>) {
2361 let at_unix = std::time::SystemTime::now()
2362 .duration_since(std::time::UNIX_EPOCH)
2363 .map_or(0, |d| d.as_secs());
2364 let next = if engaged {
2365 tear_types::Freio::Engaged { at_unix }
2366 } else {
2367 tear_types::Freio::Released
2368 };
2369
2370 let mut r = self.registry.write();
2371 let mut braked = Vec::new();
2372 let mut unbrakable = Vec::new();
2373 for (sid, s) in r.sessions.iter_mut() {
2374 if session.is_some_and(|want| want != *sid) {
2375 continue;
2376 }
2377 s.freio = next;
2378 if engaged {
2379 for (pid, p) in &s.panes {
2380 if p.yurai.is_automation() {
2381 braked.push(*pid);
2382 } else if matches!(p.yurai, tear_types::Yurai::Unknown) {
2383 unbrakable.push(*pid);
2384 }
2385 }
2386 }
2387 }
2388 let states = r.sessions.iter().map(|(id, s)| (*id, s.freio)).collect();
2389 (states, braked, unbrakable)
2390 }
2391
2392 /// Every session's brake state.
2393 #[must_use]
2394 pub fn freio_state(&self) -> Vec<(SessionId, tear_types::Freio)> {
2395 self.registry
2396 .read()
2397 .sessions
2398 .iter()
2399 .map(|(id, s)| (*id, s.freio))
2400 .collect()
2401 }
2402}
2403
2404/// Provenance-aware spawn verbs.
2405///
2406/// Inherent rather than trait methods: `MultiplexerControl` is
2407/// implemented by backends with no provenance model (the tmux backend),
2408/// and widening the trait would force them to carry a concept they cannot
2409/// honour. The trait verbs delegate here with `Yurai::Unknown`.
2410impl InProcess {
2411
2412 /// Spawn a session, recording WHO asked. See [`tear_types::yurai`].
2413 pub fn new_session_yurai(
2414 &self,
2415 name: &str,
2416 shell: &str,
2417 args: &[String],
2418 source: tear_types::SessionSource,
2419 size_cells: (u16, u16),
2420 yurai: tear_types::Yurai,
2421 ) -> ControlResult<SessionId> {
2422 let size = (size_cells.0.max(1), size_cells.1.max(1));
2423 // The embedder's cwd projection is the same value
2424 // `spawn_pty_for` will apply, so recording it here keeps the
2425 // typed pane record and the real child in agreement.
2426 let cwd = self.spawn_env.read().cwd.clone();
2427 let mut r = self.registry.write();
2428 let sid = r.create_session(name);
2429 // Stamp provenance on the typed session entry. The
2430 // registry.create_session built it with Source::default()
2431 // (Human); overwrite when the caller asked for something
2432 // else.
2433 if let Some(s) = r.sessions.get_mut(&sid) {
2434 s.source = source.clone();
2435 }
2436 let Some((_wid, pane_id)) =
2437 r.add_window(sid, "main", shell, args, cwd.as_deref(), &[], size, yurai.clone())
2438 else {
2439 return Err(ControlError::Internal(anyhow::anyhow!(
2440 "registry.add_window returned None after fresh create_session"
2441 )));
2442 };
2443 drop(r); // release write lock before spawning PTY
2444 if let Err(e) = self.spawn_pty_for(pane_id, shell, args, size, yurai.clone()) {
2445 // Roll back the session — registry is small, easier to
2446 // remove than to leave a sessionless typed entry.
2447 self.registry.write().sessions.remove(&sid);
2448 return Err(ControlError::Internal(e));
2449 }
2450 info!(
2451 session = %sid,
2452 name,
2453 shell,
2454 source = %source.label(),
2455 cols = size.0,
2456 rows = size.1,
2457 "tear-core: new session"
2458 );
2459 Ok(sid)
2460 }
2461
2462
2463 /// Open a window, recording WHO asked.
2464 pub fn new_window_yurai(
2465 &self,
2466 session: SessionId,
2467 name: &str,
2468 shell: &str,
2469 args: &[String],
2470 yurai: tear_types::Yurai,
2471 ) -> ControlResult<WindowId> {
2472 let size = (80, 24);
2473 let cwd = self.spawn_env.read().cwd.clone();
2474 let (wid, pid) = {
2475 let mut r = self.registry.write();
2476 r.add_window(session, name, shell, args, cwd.as_deref(), &[], size, yurai.clone())
2477 .ok_or(ControlError::NoSuchSession(session))?
2478 };
2479 if let Err(e) = self.spawn_pty_for(pid, shell, args, size, yurai.clone()) {
2480 return Err(ControlError::Internal(e));
2481 }
2482 info!(session = %session, window = %wid, name, "tear-core: new window");
2483 Ok(wid)
2484 }
2485
2486
2487 /// Split a pane, recording WHO asked.
2488 ///
2489 /// The new pane inherits the provenance of the CONNECTION that asked
2490 /// for the split, not of the pane it split from: an operator splitting
2491 /// an agent's pane gets their own pane, and the brake leaves it alone.
2492 pub fn split_pane_yurai(
2493 &self,
2494 origin: PaneId,
2495 direction: Direction,
2496 shell: &str,
2497 args: &[String],
2498 yurai: tear_types::Yurai,
2499 ) -> ControlResult<PaneId> {
2500 // Correct split: replace ONLY the origin leaf in the window's
2501 // layout tree with a balanced split (origin, new). Every other
2502 // pane keeps its slot — no whole-window re-wrap. Geometry is
2503 // reflowed from the tree afterwards (apply_layout_geometry).
2504 let (sid, wid) = self
2505 .registry
2506 .read()
2507 .locate_pane(origin)
2508 .ok_or(ControlError::NoSuchPane(origin))?;
2509 // Placeholder spawn size; apply_layout_geometry SIGWINCHes the
2510 // real per-pane geometry right after the PTY is up.
2511 let size = (80, 12);
2512 let cwd = self.spawn_env.read().cwd.clone();
2513 let pid = {
2514 let mut r = self.registry.write();
2515 let Some(s) = r.sessions.get_mut(&sid) else {
2516 return Err(ControlError::NoSuchSession(sid));
2517 };
2518 let new_pid = crate::registry::mint_pane_id(wid, shell);
2519 s.panes.insert(
2520 new_pid,
2521 TearPane {
2522 id: new_pid,
2523 shell: shell.into(),
2524 args: args.to_vec(),
2525 cwd: cwd.clone(),
2526 env: vec![],
2527 size_cells: size,
2528 origin_cells: (0, 0),
2529 state: tear_types::PaneState::Running,
2530 title: shell.into(),
2531 input_policy: tear_types::InputPolicy::default(),
2532 // A split inherits the provenance of whoever asked
2533 // for the split — not of the pane it split from.
2534 yurai: yurai.clone(),
2535 },
2536 );
2537 // Split the matched leaf only. If `origin` isn't in this
2538 // window's tree (it should be — we just located it), roll
2539 // back the pane we inserted so we never leave an orphan.
2540 let split_ok = s.windows.get_mut(&wid).is_some_and(|w| {
2541 let ok = w.layout.split_leaf(origin, new_pid, direction, 0.5);
2542 if ok {
2543 w.active_pane = new_pid;
2544 }
2545 ok
2546 });
2547 if !split_ok {
2548 s.panes.remove(&new_pid);
2549 return Err(ControlError::NoSuchPane(origin));
2550 }
2551 new_pid
2552 };
2553 self.spawn_pty_for(pid, shell, args, size, yurai.clone())
2554 .map_err(ControlError::Internal)?;
2555 self.apply_layout_geometry(sid, wid);
2556 info!(pane = %pid, "tear-core: split pane");
2557 Ok(pid)
2558 }
2559}