use std::collections::{HashMap, VecDeque}; use std::io; use std::sync::Arc; use gpui::{App, Global}; use gpui_component::WindowExt as _; use tty7_core::core::machine::{ AgentFacts, Axis as TreeAxis, LayoutDelta, Machine, PaneNode, PaneRecord, PaneSeed, Side, Tab as TreeTab, TabId, Workspace, }; use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk}; use tty7_core::host::HostId; use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore}; use crate::ui::app::Tty7App; use crate::ui::i18n::{L10nKey, t}; use crate::ui::pane::{Pane, PaneSlot}; /// The control link to `host`'s daemon, if one is up right now. /// /// The unification the whole design leans on: the local machine's link lives in /// [`LocalLink`](crate::ui::local_link::LocalLink), a remote machine's in /// [`HostLinks`](crate::ui::remote_connect::HostLinks), and /// everything above this function stops caring which. `None` is always /// transient (both holders have supervisors reconnecting), so callers treat it /// as "not now": mark dirty and let the re-pull that follows reconnection /// resend what still matters. pub(crate) fn control_for(cx: &mut App, host: HostId) -> Option> { if host.is_local() { crate::ui::local_link::LocalLink::client(cx) } else { crate::ui::remote_connect::HostLinks::get(cx, host) .map(|h| Arc::clone(h.client())) .filter(|c| c.is_connected()) } } /// The control link to `host`, seen by a caller about to speak the tree verbs. /// /// [`TreeLink::Unserved`] is the difference from [`control_for`]'s plain /// `None`: the peer is connected but does not advertise /// [`feature::MACHINE_TREE`](tty7_core::daemon::control::feature::MACHINE_TREE) /// — a server with no home directory to keep a tree in, or one predating the /// verbs. "Down" is transient and retried; "unserved" is a fact about the /// peer, and sending it tree verbs anyway would only trade this one clear /// state for a refusal (or, on an old enough peer, a decode failure) per /// operation. pub(crate) enum TreeLink { Ready(Arc), Unserved, Down, } pub(crate) fn tree_control_for(cx: &mut App, host: HostId) -> TreeLink { classify_tree_link(control_for(cx, host)) } /// The judgement half of [`tree_control_for`]: what the handshake's /// capability bits say this link is good for. fn classify_tree_link(client: Option>) -> TreeLink { match client { Some(client) if client .hello() .has_feature(tty7_core::daemon::control::feature::MACHINE_TREE) => { TreeLink::Ready(client) } Some(_) => TreeLink::Unserved, None => TreeLink::Down, } } fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { WorkspaceStore::all(cx) .get(client_ws) .and_then(|w| w.host.as_ref()) .map(|r| r.workspace) .unwrap_or(client_ws) } #[derive(Debug, Clone)] pub(crate) struct DesiredTab { pub id: TabId, pub name: Option, pub group: Option, pub root: DesiredNode, } #[derive(Debug, Clone)] pub(crate) enum DesiredNode { Leaf { pane: u64, seed: PaneSeed, }, Split { axis: TreeAxis, ratio: f32, a: Box, b: Box, }, } impl DesiredNode { fn first_leaf(&self) -> (&u64, &PaneSeed) { match self { DesiredNode::Leaf { pane, seed } => (pane, seed), DesiredNode::Split { a, .. } => a.first_leaf(), } } fn to_pane_node(&self) -> PaneNode { match self { DesiredNode::Leaf { pane, .. } => PaneNode::Leaf { pane: *pane }, DesiredNode::Split { axis, ratio, a, b } => PaneNode::Split { axis: *axis, ratio: *ratio, a: Box::new(a.to_pane_node()), b: Box::new(b.to_pane_node()), }, } } fn seed_of(&self, pane: u64) -> Option<&PaneSeed> { match self { DesiredNode::Leaf { pane: p, seed } => (*p == pane).then_some(seed), DesiredNode::Split { a, b, .. } => a.seed_of(pane).or_else(|| b.seed_of(pane)), } } } /// Read the window's tabs into the daemon's shape. Tabs with nothing /// representable yet (every pane still spawning) are omitted from the desired /// list — but their identities are answered separately as *held*: the tab is /// occupied, its panes just have no ids yet, and a diff that read its absence /// as "closed" would delete the daemon tab (and spend the very records) a /// revival in flight is about to replace. /// /// Held is strictly for the *transient* case. A remote window's tab that is /// native-SSH through and through is unrepresentable **forever** — its panes /// live in this client's daemon — and is neither desired nor held: as far as /// this machine's tree is concerned, it does not exist. Holding it instead /// would freeze the whole window's ordering and active-tab sync permanently, /// because [`diff`] waits out held tabs before touching either. pub(crate) fn desired_tabs( app: &Tty7App, cx: &App, ) -> (Vec, Option, Vec) { let remote = WorkspaceStore::all(cx) .get(app.workspace) .is_some_and(|w| w.is_remote()); let mut out = Vec::new(); let mut active = None; let mut held = Vec::new(); for (index, tab) in app.tabs.iter().enumerate() { let Some(root) = desired_node(&tab.pane, remote, cx) else { if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) { held.push(tab.tree_id.get()); } continue; }; let id = tab.tree_id.get(); if index == app.active { active = Some(id); } out.push(DesiredTab { id, name: tab.name.clone(), group: tab .sidebar_group .borrow() .as_ref() .map(|p| p.to_string_lossy().into_owned()), root, }); } (out, active, held) } /// Whether every leaf of `pane` is a *ready* native-SSH view — the one kind /// of leaf a remote window can never name in its machine's tree, because the /// pane lives in this client's own daemon. Only meaningful for a tab whose /// desired root came out `None`: it decides permanently-invisible versus /// held (see [`desired_tabs`]). A connecting or empty leaf answers `false` — /// those are pending, not foreign. fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { match pane { Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(), Pane::Leaf(PaneSlot::Connecting(_)) | Pane::Empty => false, Pane::Split { a, b, .. } => { every_leaf_is_native_ssh(a, cx) && every_leaf_is_native_ssh(b, cx) } } } fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option { match pane { Pane::Leaf(PaneSlot::Ready(view)) => { let view = view.read(cx); let ssh_spec = view.ssh_spec(); if remote_window && ssh_spec.is_some() { return None; } let agent = view.agent().map(|agent| { let session = view.agent_session(); AgentFacts { agent, session_id: session.as_ref().and_then(|s| s.session_id.clone()), launch_argv: session.as_ref().and_then(|s| s.launch_argv.clone()), status: None, } }); Some(DesiredNode::Leaf { pane: view.pane_id, seed: PaneSeed { pane: view.pane_id, cwd: view .spawnable_cwd() .map(|p| p.to_string_lossy().into_owned()), ssh_spec, agent, // Only a pane this window spawned knows this; one it // attached to never saw the command line. The daemon fills // that gap from its own side, so leaving it empty here // withholds nothing the tree does not already get. shell: view.shell_spec(), }, }) } Pane::Leaf(PaneSlot::Connecting(pending)) => { let spawn = &pending.read(cx).spawn; let pane = spawn.restore_pane?; let agent = spawn.agent.map(|agent| AgentFacts { agent, session_id: spawn.agent_session_id.clone(), launch_argv: spawn.agent_launch_argv.clone(), status: None, }); Some(DesiredNode::Leaf { pane, seed: PaneSeed { pane, cwd: spawn .working_directory .as_ref() .map(|p| p.to_string_lossy().into_owned()), ssh_spec: None, agent, shell: spawn.shell.clone(), }, }) } Pane::Split { axis, a, b, ratio, .. } => { let left = desired_node(a, remote_window, cx); let right = desired_node(b, remote_window, cx); match (left, right) { (Some(a), Some(b)) => Some(DesiredNode::Split { axis: match axis { gpui::Axis::Horizontal => TreeAxis::Horizontal, gpui::Axis::Vertical => TreeAxis::Vertical, }, ratio: ratio.get(), a: Box::new(a), b: Box::new(b), }), (one, other) => one.or(other), } } Pane::Empty => None, } } /// The records the machine's `register_pane` will mint from the seeds a sync /// pushes, for the window to mirror itself — they come back to it in nothing /// it is sent (#612). Its own terminals stand in for the machine's liveness /// probe: a pane this window holds open is one that registry answers alive, and /// one still connecting is not yet anyone's to call — later facts settle it. fn seeded_records(desired: &[DesiredTab], live: impl Fn(u64) -> bool) -> Vec { fn walk(node: &DesiredNode, live: &impl Fn(u64) -> bool, out: &mut Vec) { match node { DesiredNode::Leaf { pane, seed } => out.push(seed.clone().into_record(live(*pane))), DesiredNode::Split { a, b, .. } => { walk(a, live, out); walk(b, live, out); } } } let mut out = Vec::new(); for tab in desired { walk(&tab.root, &live, &mut out); } out } #[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct WsMirror { pub tabs: Vec, pub active: Option, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum SyncScope { Full, Additive, } pub(crate) fn diff( workspace: WorkspaceId, mirror: &mut WsMirror, desired: &[DesiredTab], desired_active: Option, scope: SyncScope, held: &[TabId], ) -> Vec { let mut ops = Vec::new(); if scope == SyncScope::Full { migrate_panes(workspace, mirror, desired, &mut ops); let mut index = 0; while index < mirror.tabs.len() { let id = mirror.tabs[index].id; if desired.iter().any(|t| t.id == id) || held.contains(&id) { index += 1; continue; } let closed = mirror.tabs.remove(index); ops.push(ControlRequest::TabClose { workspace, tab: closed.id, }); heal_active(mirror, index); } } // The tabs the machine already has are settled first, and only then are the // new ones built. A pane that left its tab to become one of its own is in // both halves of that — the machine refuses to register a pane it already // holds, so the tab it left has to give it up before the tab it is // becoming can ask for it. for want in desired { if let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) { reconcile_tab(workspace, mirror, at, want, &mut ops); } } for (index, want) in desired.iter().enumerate() { if mirror.tabs.iter().any(|t| t.id == want.id) { continue; } let at = match scope { SyncScope::Full => index, SyncScope::Additive => mirror.tabs.len(), }; create_tab(workspace, mirror, at, want, &mut ops); } if scope == SyncScope::Additive || !held.is_empty() { return ops; } for (index, want) in desired.iter().enumerate() { let at = mirror .tabs .iter() .position(|t| t.id == want.id) .expect("every desired tab exists after the passes above"); if at != index { let tab = mirror.tabs.remove(at); mirror.tabs.insert(index, tab); ops.push(ControlRequest::TabMove { workspace, tab: want.id, to: index as u64, }); } } if let Some(active) = desired_active && mirror.active != Some(active) && mirror.tabs.iter().any(|t| t.id == active) { mirror.active = Some(active); ops.push(ControlRequest::WorkspaceSetActiveTab { workspace, tab: active, }); } ops } /// Carries panes across to the tab that now wants them, before anything else /// gets a chance to read their old tab as one to close. /// /// This is a tab dragged into another tab's layout: every pane it brought /// keeps running and changes tab, and the tab it came from goes away once it /// has nothing left. Told as `PaneMove`, which is the one op that can say that /// — closing the old tab and building the new one would say instead that a /// tab's worth of panes went away and a tab's worth arrived. /// /// One move per round, because each move changes where the next one can go: /// a pane may only land beside a pane its destination already holds, so a /// two-pane tab crosses as its first pane and then the rest beside it. A round /// that finds nothing left to do ends the pass, which is also what happens /// when a move cannot be spelled this way at all — the passes below then treat /// it as the reshape it is. fn migrate_panes( workspace: WorkspaceId, mirror: &mut WsMirror, desired: &[DesiredTab], ops: &mut Vec, ) { while let Some((pane, to, axis, first)) = next_migration(mirror, desired) { let holders = |m: &WsMirror, p: u64| m.tabs.iter().position(|t| t.root.contains(p)); let (Some(from), Some(dest)) = (holders(mirror, pane), holders(mirror, to)) else { return; }; // The destination takes the pane before its old tab gives it up, so a // split that somehow will not take leaves the mirror as it was rather // than a pane short of the tree the daemon has. if !mirror.tabs[dest] .root .split_leaf(to, pane, axis, 0.5, first) { return; } // The rest of the bookkeeping `Machine::pane_move` does at the other // end: a tab that has just lost its last pane is gone, and the active // tab heals onto whatever took its place. if mirror.tabs[from].root.remove_leaf(pane).is_none() { mirror.tabs.remove(from); heal_active(mirror, from); } ops.push(ControlRequest::PaneMove { workspace, pane, to, axis, first, }); } } /// The next pane sitting in a tab that no longer wants it, and the pane in the /// tab that does that it can be put beside. fn next_migration(mirror: &WsMirror, desired: &[DesiredTab]) -> Option<(u64, u64, TreeAxis, bool)> { for want in desired { let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) else { continue; }; let root = want.root.to_pane_node(); // Panes the machine has never heard of are splits, not moves: a side // holding one of those is not a side that can cross over. let arriving = |node: &PaneNode| { let ids = node.pane_ids(); !ids.is_empty() && ids.iter().all(|p| { mirror .tabs .iter() .position(|t| t.root.contains(*p)) .is_some_and(|holder| holder != at) }) }; let settled = |node: &PaneNode| match node { PaneNode::Leaf { pane } => mirror.tabs[at].root.contains(*pane).then_some(*pane), _ => None, }; if let Some(step) = arrival_site(&root, &arriving, &settled) { return Some(step); } } None } /// The split in the shape a tab wants where panes still living in another tab /// meet a pane that is already here, read as a pane to move and the pane to /// put it beside. /// /// The side that is arriving may be a whole subtree — only its first pane /// crosses on this round, and the ones behind it follow on later rounds, by /// which time this same reading finds them the sites they want inside it. The /// side that is staying has to be a single pane, because that is all a move can /// split. Nothing else can be said in one move: a tab dropped against the outer /// edge of a layout that is more than one pane deep has to go in above the /// whole of it, and the passes after this one rebuild the tab instead. fn arrival_site( node: &PaneNode, arriving: &impl Fn(&PaneNode) -> bool, settled: &impl Fn(&PaneNode) -> Option, ) -> Option<(u64, u64, TreeAxis, bool)> { let PaneNode::Split { axis, a, b, .. } = node else { return None; }; let first = |side: &PaneNode| side.pane_ids().first().copied(); if let (Some(to), true) = (settled(a), arriving(b)) { return Some((first(b)?, to, *axis, false)); } if let (true, Some(to)) = (arriving(a), settled(b)) { return Some((first(a)?, to, *axis, true)); } arrival_site(a, arriving, settled).or_else(|| arrival_site(b, arriving, settled)) } fn heal_active(mirror: &mut WsMirror, removed: usize) { let named = mirror .active .is_some_and(|active| mirror.tabs.iter().any(|t| t.id == active)); if named { return; } if mirror.tabs.is_empty() { mirror.active = None; return; } mirror.active = Some(mirror.tabs[removed.min(mirror.tabs.len() - 1)].id); } fn create_tab( workspace: WorkspaceId, mirror: &mut WsMirror, index: usize, want: &DesiredTab, ops: &mut Vec, ) { let (first, seed) = want.root.first_leaf(); ops.push(ControlRequest::TabCreate { workspace, at: Some(index as u64), pane: seed.clone(), tab: Some(want.id), }); let mut root = PaneNode::Leaf { pane: *first }; materialize_splits(workspace, &want.root, &mut root, ops); if want.name.is_some() { ops.push(ControlRequest::TabRename { workspace, tab: want.id, name: want.name.clone(), }); } if want.group.is_some() { ops.push(ControlRequest::TabSetGroup { workspace, tab: want.id, group: want.group.clone(), }); } mirror.tabs.insert( index.min(mirror.tabs.len()), TreeTab { id: want.id, name: want.name.clone(), sidebar_group: want.group.clone(), root, }, ); mirror.active = Some(want.id); } fn materialize_splits( workspace: WorkspaceId, want: &DesiredNode, root: &mut PaneNode, ops: &mut Vec, ) { let DesiredNode::Split { axis, ratio, a, b } = want else { return; }; let (anchor, _) = a.first_leaf(); let (new, seed) = b.first_leaf(); ops.push(ControlRequest::PaneSplit { workspace, pane: *anchor, axis: *axis, ratio: *ratio, new: seed.clone(), first: false, }); root.split_leaf(*anchor, *new, *axis, *ratio, false); materialize_splits(workspace, a, root, ops); materialize_splits(workspace, b, root, ops); } fn reconcile_tab( workspace: WorkspaceId, mirror: &mut WsMirror, at: usize, want: &DesiredTab, ops: &mut Vec, ) { { let tab = &mut mirror.tabs[at]; if tab.name != want.name { tab.name = want.name.clone(); ops.push(ControlRequest::TabRename { workspace, tab: want.id, name: want.name.clone(), }); } if tab.sidebar_group != want.group { tab.sidebar_group = want.group.clone(); ops.push(ControlRequest::TabSetGroup { workspace, tab: want.id, group: want.group.clone(), }); } } let desired_root = want.root.to_pane_node(); if mirror.tabs[at].root == desired_root { return; } if same_shape_and_panes(&mirror.tabs[at].root, &desired_root) { fix_ratios( workspace, want.id, &mut mirror.tabs[at].root, &desired_root, ops, ); return; } let have = mirror.tabs[at].root.pane_ids(); let wanted = desired_root.pane_ids(); let added: Vec = wanted .iter() .copied() .filter(|p| !have.contains(p)) .collect(); let removed: Vec = have .iter() .copied() .filter(|p| !wanted.contains(p)) .collect(); let done = match (added.as_slice(), removed.as_slice()) { ([new], []) => try_single_split(workspace, mirror, at, want, &desired_root, *new, ops), ([], []) => try_single_move(workspace, mirror, at, &desired_root, ops), ([], gone) if !gone.is_empty() => { for pane in gone { mirror.tabs[at].root.remove_leaf(*pane); ops.push(ControlRequest::PaneClose { workspace, pane: *pane, }); } same_shape_and_panes(&mirror.tabs[at].root, &desired_root) } ([new], [old]) => { let elsewhere = mirror .tabs .iter() .enumerate() .any(|(i, t)| i != at && t.root.contains(*new)); let mut predicted = mirror.tabs[at].root.clone(); predicted.replace_leaf(*old, *new); if !elsewhere && same_shape_and_panes(&predicted, &desired_root) { let seed = want .root .seed_of(*new) .expect("the added pane is a desired leaf") .clone(); mirror.tabs[at].root = predicted; ops.push(ControlRequest::PaneReplace { workspace, old: *old, new: seed, }); true } else { false } } _ => false, }; if done { fix_ratios( workspace, want.id, &mut mirror.tabs[at].root, &desired_root, ops, ); return; } let closed = mirror.tabs.remove(at); ops.push(ControlRequest::TabClose { workspace, tab: closed.id, }); heal_active(mirror, at); create_tab(workspace, mirror, at, want, ops); } fn try_single_split( workspace: WorkspaceId, mirror: &mut WsMirror, at: usize, want: &DesiredTab, desired_root: &PaneNode, new: u64, ops: &mut Vec, ) -> bool { let Some((sibling, axis, ratio, first)) = split_site(desired_root, new) else { return false; }; let mut predicted = mirror.tabs[at].root.clone(); if !predicted.split_leaf(sibling, new, axis, ratio, first) { return false; } if !same_shape_and_panes(&predicted, desired_root) { return false; } let seed = want .root .seed_of(new) .expect("the added pane is a desired leaf") .clone(); mirror.tabs[at].root = predicted; ops.push(ControlRequest::PaneSplit { workspace, pane: sibling, axis, ratio, new: seed, first, }); true } /// Reshapes a tab that still holds exactly the panes it did, when one pane /// changing places accounts for the whole difference. /// /// That is what dragging a pane across the layout is, and it is worth spotting: /// the fallback for a reshape is to close the tab and build it again, which /// tells every other reader of the machine that a tab went away and came back /// when all that happened was a pane sliding sideways. /// /// A swap of two panes that are not each other's siblings is not one move, and /// still takes the fallback. fn try_single_move( workspace: WorkspaceId, mirror: &mut WsMirror, at: usize, desired_root: &PaneNode, ops: &mut Vec, ) -> bool { for pane in mirror.tabs[at].root.pane_ids() { let Some((to, axis, _, first)) = split_site(desired_root, pane) else { continue; }; let mut predicted = mirror.tabs[at].root.clone(); if predicted.remove_leaf(pane) != Some(true) { continue; } // The daemon re-splits at a half whatever the wanted ratio is, so the // mirror has to predict that half; `fix_ratios` settles the rest. if !predicted.split_leaf(to, pane, axis, 0.5, first) || !same_shape_and_panes(&predicted, desired_root) { continue; } mirror.tabs[at].root = predicted; ops.push(ControlRequest::PaneMove { workspace, pane, to, axis, first, }); return true; } false } fn split_site(node: &PaneNode, new: u64) -> Option<(u64, TreeAxis, f32, bool)> { let PaneNode::Split { axis, ratio, a, b } = node else { return None; }; match (&**a, &**b) { (PaneNode::Leaf { pane }, sibling) if *pane == new => { if let PaneNode::Leaf { pane: s } = sibling { return Some((*s, *axis, *ratio, true)); } return None; } (sibling, PaneNode::Leaf { pane }) if *pane == new => { if let PaneNode::Leaf { pane: s } = sibling { return Some((*s, *axis, *ratio, false)); } return None; } _ => {} } if a.contains(new) { split_site(a, new) } else if b.contains(new) { split_site(b, new) } else { None } } fn same_shape_and_panes(a: &PaneNode, b: &PaneNode) -> bool { match (a, b) { (PaneNode::Leaf { pane: pa }, PaneNode::Leaf { pane: pb }) => pa == pb, ( PaneNode::Split { axis: ax, a: aa, b: ab, .. }, PaneNode::Split { axis: bx, a: ba, b: bb, .. }, ) => ax == bx && same_shape_and_panes(aa, ba) && same_shape_and_panes(ab, bb), _ => false, } } fn fix_ratios( workspace: WorkspaceId, tab: TabId, mirror: &mut PaneNode, desired: &PaneNode, ops: &mut Vec, ) { fn walk( workspace: WorkspaceId, tab: TabId, mirror: &mut PaneNode, desired: &PaneNode, path: &mut Vec, ops: &mut Vec, ) { let ( PaneNode::Split { ratio: mr, a: ma, b: mb, .. }, PaneNode::Split { ratio: dr, a: da, b: db, .. }, ) = (mirror, desired) else { return; }; if (*mr - *dr).abs() > 1e-4 { *mr = *dr; ops.push(ControlRequest::PaneSetRatio { workspace, tab, path: path.clone(), ratio: *dr, }); } path.push(Side::A); walk(workspace, tab, ma, da, path, ops); path.pop(); path.push(Side::B); walk(workspace, tab, mb, db, path, ops); path.pop(); } let mut path = Vec::new(); walk(workspace, tab, mirror, desired, &mut path, ops); } enum SyncPhase { Unprimed { dirty: bool, priming: bool }, Primed(WsMirror), } struct WsState { sync: SyncPhase, queue: VecDeque, inflight: bool, informed: bool, epoch: u64, /// A hydration that failed and still owes this window its layout. /// /// Whatever the window is showing is not the layout it was told to put up, /// so nothing may be pushed from it until the pull is retried — an emptied /// window diffs into "close every tab" and would wipe the layout off the /// machine, and a window full of dead tabs pushes those back up as truth. rehydrate: Option, /// The tabs the window held when the pull that is owed was ordered. /// /// A `Replace` retry is dropped once the user has filled the window in /// themselves, and this is what "the user" means. A `Replace` is ordered /// precisely *because* what the window is showing cannot be trusted — dead /// panes left by a daemon that came back as a new process, a window emptied /// for a handoff that was then refused — so the tabs that were already /// there are the very thing it exists to replace, and can never be the /// reason to abandon it. Only a tab this debt never saw says the user moved /// on without us. /// /// Rewritten by every attempt, and only ever read while `rehydrate` is /// outstanding, so it always describes the debt currently standing. owed_over: Vec, /// The tabs the last rebuild was handed and could not put up — on the /// machine and in the mirror, but not on screen, because no pane in them /// would start (`tabs_from_session` drops such a tab, and says so). /// /// Held out of every diff, the way a tab still connecting is: the window /// cannot speak for them, and its licence to prune must not read their /// absence as the user closing them. A rebuild that put up some of its tabs /// earned the licence — it did put a layout up — and used it to `TabClose` /// exactly the tabs it had failed to rebuild, deleting them off the machine, /// panes and all (#672). Rewritten by the next rebuild, which either puts /// them up or fails them again; a tab the machine drops meanwhile drops /// out here too, so nothing stays held for a tab nobody has. not_rebuilt: Vec, /// How many pulls in a row this window has owed, which paces the retry. /// /// Counts consecutive failures, so it is cleared by anything that ends the /// run: a pull that lands (`finish_hydration`), a prime that lands /// (`finish_prime` — the machine answered, which is the whole question), /// and a debt abandoned rather than paid (`take_rehydrate` dropping a /// `Replace` the user has overtaken). A machine that hiccups once is then /// asked again promptly, and one that is really gone is not asked in a /// loop. /// /// Leaving it standing after the run ends is what makes a *first* failure /// wait the cap: the count would still be carrying an outage that is over. rehydrate_attempts: u32, /// A folder the launch asked for, waiting for this window's layout. /// /// Held here rather than opened straight away because a window with a tab /// in it is one `Adopt::IfEmpty` will not adopt into: the pull would land, /// decline the layout, and push the single tab back as the whole workspace. /// Parking it also means a pull that has to be retried still gets the /// folder opened, on whichever attempt finally lands. then_open: Option, /// A name the user typed for a workspace this window is about to create. /// /// It has to travel with the create rather than follow it as a rename: the /// workspace does not exist on the machine yet, so a rename sent now is /// answered `NotFound` and dropped, and the create that runs afterwards /// names it whatever `fresh_workspace_name` rolled (#618). Consumed by /// `start_prime`, which spends it instead of the generated name, and /// cleared by `finish_prime` once the machine has confirmed a name. chosen_name: Option, /// Panes this window has put down that may have nothing left holding them. /// /// Parked rather than ended on the spot. At the moment a layout is /// rewritten neither mirror can be trusted to say whether a pane is still /// somebody's: the machine copy is missing whatever this window has just /// done (a client is left out of the deltas its own ops raise, #612), and /// even this workspace's own copy runs ahead of, or behind, ops still in /// flight. Judging there ends live panes — measured, as the fuzz's `tree /// names panes that do not exist`. /// /// So the question is asked later, against a tree that was just pulled and /// is therefore authoritative, and only then is anything hung up. A pane /// that came back into the tree or back onto the screen in the meantime is /// exactly the one the mirrors were wrong about, and is simply forgotten. parked: std::collections::HashSet, /// Every pane this window has brought into existence, kept for as long as /// the window runs. /// /// [`Self::parked`] is a list of suspects, filed by whichever site noticed /// it was putting a pane down. That is what kept the leak alive: the sites /// that file are not the sites that strand. A hydration whose whole session /// is discarded before it is ever adopted never compares a before against /// an after, so its panes are never parked, and they are the majority — /// `tab new` followed straight away by `tab close` stranded a live shell on /// five runs out of six while every parking site behaved perfectly. /// /// So this is a census rather than a suspicion, and the judgement is the /// same one [`stranded_of`] already makes: a pane no window is showing and /// no workspace on the machine names is nobody's, however it came to be /// that way. Nothing here decides that a pane is doomed; it only makes the /// pane visible to the question. /// /// Kept rather than cleared, unlike `parked` — a pane can be perfectly held /// at one sweep and stranded by the next. Panes actually hung up are /// dropped, so the set tracks the window's living panes and does not grow /// with the session. Pane ids are never reused (the daemon carries /// `next_pane_id` across a handoff for exactly that reason), so an id that /// outlives its pane can only ever name that pane. spawned: std::collections::HashSet, /// Whether this window has already been told why it opened empty. /// /// The retry is as quiet as the failure was, so a window whose machine /// never answers re-enters `hydrate` on every `sync_window` and would say /// the same thing again every fifteen seconds. Saying it once is the /// point; saying it on a loop is noise. Cleared once a pull lands, so a /// later outage is still worth a word. said_why_empty: bool, } impl Default for WsState { fn default() -> Self { WsState { sync: SyncPhase::Unprimed { dirty: false, priming: false, }, queue: VecDeque::new(), inflight: false, informed: false, epoch: 0, rehydrate: None, owed_over: Vec::new(), not_rebuilt: Vec::new(), rehydrate_attempts: 0, then_open: None, chosen_name: None, parked: std::collections::HashSet::new(), spawned: std::collections::HashSet::new(), said_why_empty: false, } } } #[derive(Default)] pub(crate) struct TreeSync { windows: HashMap, } impl Global for TreeSync {} pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { let client_ws = app.workspace; if !cx.has_global::() { return; } if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) { return; } let showing: Vec = app.tabs.iter().map(|t| t.tree_id.get()).collect(); if let Some(adopt) = take_rehydrate(cx, client_ws, &showing) { // The app is mid-update here, so the tabs it knows it is showing come // from `app` itself, not from reading the entity back. hydrate_with(cx, client_ws, adopt, showing); return; } adopt_tab_ids(app, cx); let (desired, desired_active, mut held) = desired_tabs(app, cx); let machine_ws = tree_workspace_id(cx, client_ws); let state = cx .default_global::() .windows .entry(client_ws) .or_default(); match &mut state.sync { SyncPhase::Unprimed { dirty, priming } => { *dirty = true; if !*priming { *priming = true; start_prime(cx, client_ws); } } SyncPhase::Primed(mirror) => { let scope = if state.informed { SyncScope::Full } else { SyncScope::Additive }; // The tabs the last rebuild could not put up are the machine's to // keep: not on screen, so `desired` cannot speak for them, and held // so their absence is not read as a close. state .not_rebuilt .retain(|id| mirror.tabs.iter().any(|t| t.id == *id)); held.extend(state.not_rebuilt.iter().copied()); let ops = diff(machine_ws, mirror, &desired, desired_active, scope, &held); if !ops.is_empty() { let (tabs, active) = (mirror.tabs.clone(), mirror.active); state.queue.extend(ops); let host = WorkspaceStore::host_of(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( cx, host, machine_ws, tabs, active, ); let open: Vec = app .tabs .iter() .flat_map(|t| t.pane.terminals()) .map(|v| v.read(cx).pane_id) .collect(); crate::ui::machine_mirror::MachineMirrors::note_seeded_panes( cx, host, seeded_records(&desired, |pane| open.contains(&pane)), ); pump(cx, client_ws); } } } } pub(crate) fn on_link_up(cx: &mut App, host: HostId) { for (workspace, app) in crate::ui::windows::WindowRegistry::open_windows(cx) { if WorkspaceStore::host_of(cx, workspace) != host { continue; } if let Some(app) = app.upgrade() { app.update(cx, |app, cx| sync_window(app, cx)); } } } /// Claims a hydration owed to `client_ws`, if one is still outstanding. /// `showing` is what the window has on screen right now. /// /// A `Replace` retry is dropped once the user has filled the window in without /// us: replaying the machine's older layout over work they did in the meantime /// would be worse than never retrying at all. /// /// What counts as "without us" is a tab the debt never saw. Judging it by the /// window merely *having* tabs read every resync of a window that kept its own /// — a daemon back as a new process, a delta that would not apply, a remote /// server restarted — as the user having moved on, when those tabs are the /// stale ones the `Replace` was ordered to sweep away. The retry was abandoned /// on its first attempt, every time, and the resync silently did nothing. fn take_rehydrate(cx: &mut App, client_ws: WorkspaceId, showing: &[TabId]) -> Option { let state = cx .default_global::() .windows .get_mut(&client_ws)?; let adopt = state.rehydrate.take()?; let overtaken = showing.iter().any(|id| !state.owed_over.contains(id)); if overtaken && adopt == Adopt::Replace { // Abandoned, not paid — but the run of failures is over either way, and // a count left standing would make the next window's first failure wait // the cap on an outage that has nothing to do with it. state.rehydrate_attempts = 0; return None; } Some(adopt) } /// Whether a window with no tabs may delete `client_ws` outright — from the /// machine's tree and from the store both. /// /// Two independent things have to agree, because the window's own emptiness /// cannot tell them apart: a workspace is empty when it genuinely holds /// nothing, and equally when its layout failed to rebuild. Only the first is a /// reason to delete anything, and the second has already cost a workspace with /// ten live tabs in it. /// /// So the window must be informed (it pulled a layout and put it up), *and* the /// mirror — the machine's own account, which no local failure can empty — must /// agree there is nothing there. An unprimed mirror knows nothing and answers /// no: "I don't know" may never authorize a deletion. pub(crate) fn workspace_is_disposable(cx: &App, client_ws: WorkspaceId) -> bool { let Some(state) = cx .try_global::() .and_then(|t| t.windows.get(&client_ws)) else { return false; }; state.informed && matches!(&state.sync, SyncPhase::Primed(mirror) if mirror.tabs.is_empty()) } pub(crate) fn mark_window_informed(cx: &mut App, client_ws: WorkspaceId) { cx.default_global::() .windows .entry(client_ws) .or_default() .informed = true; } fn adopt_tab_ids(app: &Tty7App, cx: &App) { let Some(TreeSync { windows }) = cx.try_global::() else { return; }; let Some(WsState { sync: SyncPhase::Primed(mirror), .. }) = windows.get(&app.workspace) else { return; }; let known: Vec = app.tabs.iter().map(|t| t.tree_id.get()).collect(); for tab in &app.tabs { let id = tab.tree_id.get(); if mirror.tabs.iter().any(|m| m.id == id) { continue; } let panes: Vec = tab .pane .terminals() .iter() .map(|v| v.read(cx).pane_id) .collect(); if panes.is_empty() { continue; } let Some(matched) = mirror .tabs .iter() .find(|m| !known.contains(&m.id) && panes.iter().any(|p| m.root.contains(*p))) else { continue; }; tab.tree_id.set(matched.id); } } pub(crate) fn on_preempted(cx: &mut App, client_ws: WorkspaceId) { let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { return; }; state.sync = SyncPhase::Unprimed { dirty: false, priming: false, }; state.queue.clear(); state.informed = false; state.epoch += 1; } pub(crate) fn forget(cx: &mut App, client_ws: WorkspaceId) { if let Some(state) = cx.try_global::() { let _ = state; cx.default_global::().windows.remove(&client_ws); } } pub(crate) fn fire_workspace_op( cx: &mut App, client_ws: WorkspaceId, op: impl FnOnce(WorkspaceId) -> ControlRequest, ) { if !cx.has_global::() { return; } let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); let request = op(machine_ws); crate::ui::machine_mirror::MachineMirrors::note_workspace_op(cx, host, &request); let client = match tree_control_for(cx, host) { TreeLink::Ready(client) => client, TreeLink::Unserved => { unsendable( &request, "this machine's server does not serve the workspace tree", ); return; } TreeLink::Down => { unsendable(&request, "there is no control link to its machine"); return; } }; cx.background_executor() .spawn(async move { if let Err(e) = client.call(request.clone()) { unsendable(&request, &format!("the machine refused it: {e}")); } }) .detach(); } fn unsendable(request: &ControlRequest, why: &str) { match request { ControlRequest::WorkspaceRemove { workspace } => log::warn!( "workspace {workspace} was deleted here but not on its machine ({why}); \ its entry stays in that machine's tree, where another client will still \ see it — delete it again from a client that can reach the machine" ), other => log::debug!("{other:?} not sent ({why}); the next edit carries it"), } } /// Names a workspace this window is in the middle of creating. /// /// Parked rather than sent: the machine has not been asked to create the /// workspace yet, so a rename addressed to it right now comes back `NotFound` /// and is dropped on the floor — which is how a name typed into the create form /// used to lose to the generated one (#618). `start_prime` spends it on the /// create itself. /// /// A window already synced with its machine has no create coming, so there is /// nothing to ride along with and the rename goes out as usual. pub(crate) fn name_new_workspace(cx: &mut App, client_ws: WorkspaceId, name: String) { // A window tree-sync has never heard of is as unprimed as one it is // priming right now: either way the create is still ahead of us. let state = cx .default_global::() .windows .entry(client_ws) .or_default(); if matches!(state.sync, SyncPhase::Unprimed { .. }) { state.chosen_name = Some(name); return; } rename_workspace(cx, client_ws, Some(name)); } /// The name parked for a create that has not run yet, for tests that need to /// see it got that far. #[cfg(test)] pub(crate) fn chosen_name_for(cx: &mut App, client_ws: WorkspaceId) -> Option { cx.default_global::() .windows .get(&client_ws) .and_then(|state| state.chosen_name.clone()) } pub(crate) fn rename_workspace(cx: &mut App, client_ws: WorkspaceId, name: Option) { fire_workspace_op(cx, client_ws, move |ws| ControlRequest::WorkspaceRename { workspace: ws, name, }); } fn start_prime(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); let client = match tree_control_for(cx, host) { TreeLink::Ready(client) => client, unavailable => { if matches!(unavailable, TreeLink::Unserved) { log::warn!( "workspace {client_ws}: its machine's server does not serve the tree; \ the layout will not be synced" ); } if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) && let SyncPhase::Unprimed { priming, .. } = &mut state.sync { *priming = false; } return; } }; let epoch = cx .default_global::() .windows .get(&client_ws) .map(|s| s.epoch) .unwrap_or(0); cx.spawn(async move |cx| { // Picked on the main thread, where the names already in use are // readable, but inside the task rather than before it: a window that is // switching workspaces orders its pull first and is named second, so // reading any earlier would miss the name the user typed. A name the // user did type beats a generated one, and is left in place for // `finish_prime` to spend against the machine's answer. let fresh = cx.update(|cx| { cx.default_global::() .windows .get(&client_ws) .and_then(|state| state.chosen_name.clone()) .unwrap_or_else(|| fresh_workspace_name(cx, host)) }); let outcome = cx .background_executor() .spawn(async move { pull_or_create(&client, machine_ws, fresh) }) .await; cx.update(|cx| finish_prime(cx, client_ws, epoch, outcome)); }) .detach(); } /// A codename no workspace on `host` is using. Beats leaving new workspaces /// named after whatever directory their first shell happened to start in — /// three of those in a switcher all read the same. pub(crate) fn fresh_workspace_name(cx: &App, host: HostId) -> String { let mut taken: Vec = Vec::new(); if let Some(machine) = crate::ui::machine_mirror::MachineMirrors::machine(cx, host) { taken.extend(machine.workspaces.iter().filter_map(|w| w.name.clone())); } // Labels are the names the switcher actually shows, which for an unnamed // workspace is its directory. Counting those as taken is deliberately // generous — it only ever costs another roll of the dice. if cx.has_global::() { taken.extend( WorkspaceStore::all(cx) .views .iter() .filter(|w| w.host_id() == host) .filter_map(|w| w.label.clone()), ); } tty7_core::core::codename::unique(|name| taken.iter().any(|t| t == name)) } /// Settles the name a window asked for against the name its machine came back /// with, and returns what the workspace is really called. /// /// `answered` is the machine's answer. A chosen name it read back was spent by /// the create that carried it, and there is nothing left to do. One it did not /// means the create never ran, and there are two ways that happens: the other /// create of this window's own pair won the race with a stale idea of the /// name, or the workspace was simply already there. Either way the name is /// owed only once — `take` runs whatever is decided below. /// /// `holds_tabs` is what separates those two. A workspace this window's sibling /// create just made is empty, so renaming it is finishing this window's own /// job. A workspace that already holds tabs was made by somebody else and is /// somebody else's: a client opening a populated workspace on another machine /// used to rename it to the codename *its* side had rolled, so a workspace /// full of another user's running panes took the connecting client's name /// (#716). Adopting a workspace is not creating one, and only the create was /// ever owed a name. fn settle_chosen_name( cx: &mut App, client_ws: WorkspaceId, answered: Option, holds_tabs: bool, ) -> Option { let chosen = cx .default_global::() .windows .get_mut(&client_ws) .and_then(|state| state.chosen_name.take()); match chosen { Some(chosen) if answered.as_deref() == Some(chosen.as_str()) => answered, // Adopted, not created. The machine's name stands. Some(_) if holds_tabs => answered, Some(chosen) => { rename_workspace(cx, client_ws, Some(chosen.clone())); Some(chosen) } None => answered, } } /// This workspace's layout, and the name the machine has for it. /// /// The name comes back even from the create this client asked for, and /// especially from that one: a client is left out of the deltas its own ops /// raise, so the `WorkspaceCreated` delta carrying the name it just proposed /// never arrives. Dropping the name here left the mirror holding the workspace /// unnamed until something pulled the whole tree again, and the name it had had /// all along then landed on screen looking like a rename (#604). fn pull_or_create( client: &ControlClient, machine_ws: WorkspaceId, fresh: String, ) -> io::Result<(WsMirror, Option)> { match client.call(ControlRequest::WorkspaceTree { workspace: machine_ws, }) { Ok(ReplyOk::WorkspaceTree(ws)) => Ok(primed(*ws)), Ok(other) => Err(io::Error::other(format!( "WorkspaceTree answered {other:?}" ))), Err(e) if e.kind() == io::ErrorKind::NotFound => { match client.call(ControlRequest::WorkspaceCreate { name: Some(fresh), workspace: Some(machine_ws), })? { ReplyOk::WorkspaceTree(ws) => Ok(primed(*ws)), other => Err(io::Error::other(format!( "WorkspaceCreate answered {other:?}" ))), } } Err(e) => Err(e), } } fn primed(ws: Workspace) -> (WsMirror, Option) { ( WsMirror { tabs: ws.tabs, active: ws.active_tab, }, ws.name, ) } fn finish_prime( cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::Result<(WsMirror, Option)>, ) { let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { return; }; if state.epoch != epoch || !matches!(state.sync, SyncPhase::Unprimed { priming: true, .. }) { log::debug!("workspace {client_ws}: dropping a superseded tree pull"); return; } let was_dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); let landed = match outcome { Ok((mirror, name)) => { state.informed |= mirror.tabs.is_empty(); // The machine answered, which is the only thing the retry was // waiting to find out, so the next failure starts its backoff over. state.rehydrate_attempts = 0; let landed = (mirror.tabs.clone(), mirror.active, name); state.sync = SyncPhase::Primed(mirror); landed } Err(e) => { log::warn!("could not pull the tree for workspace {client_ws}: {e}"); state.sync = SyncPhase::Unprimed { dirty: was_dirty, priming: false, }; return; } }; let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); let holds_tabs = !landed.0.is_empty(); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( cx, host, machine_ws, landed.0, landed.1, ); // The pull above is the only place this window will hear the workspace's // name — it is left out of the deltas its own create raises (#604). let name = settle_chosen_name(cx, client_ws, landed.2, holds_tabs); crate::ui::machine_mirror::MachineMirrors::note_workspace_name(cx, host, machine_ws, name); // On every prime that landed, including the ones that go no further: a // refused operation ends here, and the pane it stranded is not going to be // mentioned again by anything. The judgement itself re-reads what the // windows are showing when it lands, so ordering against the rebuild below // does not matter. settle_census(cx, client_ws); if !was_dirty { return; } let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) else { return; }; app.update(cx, |app, cx| sync_window(app, cx)); } /// Every pane **any** open window is showing, or `None` when that cannot be /// established. /// /// Every window, not just the one that raised the op. The answer this guards is /// the daemon's, and the daemon's is machine-wide: a pane another window has /// spawned and registered but not yet placed in the tree is named as detached, /// because at that instant nothing in the tree holds it. Asking only the window /// in hand would end a pane a *different* window is showing — the same mistake /// as judging one against a tree that has not caught up, one window over. /// /// `None` is not "nothing is showing" — it is "do not act on this", and /// [`hang_up_detached`] treats it that way. A window that will not answer might /// be showing anything, so a window that fails to upgrade abandons the sweep /// rather than shrinking it. A leaked shell is recoverable with `tty7 pane /// close --orphans`; a shell ended under a live window is not. /// /// A slot still connecting counts by the pane it is reattaching to. That is the /// one worth protecting: the daemon has registered it, so it can be named as /// detached, while the window has nothing on screen for it yet. pub(crate) fn shown_pane_ids(cx: &mut App) -> Option> { let mut shown = std::collections::HashSet::new(); for (_, weak) in crate::ui::windows::WindowRegistry::open_windows(cx) { let app = weak.upgrade()?; shown.extend(pane_ids_of(app.read(cx), cx)); } Some(shown) } /// The same question asked of a window already in hand, which is how a caller /// inside an `app.update` has to ask it: reading the entity back from there is /// the borrow conflict gpui answers by dropping the update. fn pane_ids_of(app: &Tty7App, cx: &App) -> std::collections::HashSet { app.tabs .iter() .flat_map(|tab| tab.pane.leaves()) .filter_map(|slot| match slot { PaneSlot::Ready(view) => Some(view.read(cx).pane_id), PaneSlot::Connecting(pending) => pending.read(cx).spawn.restore_pane, }) .collect() } /// Ends the shells behind panes the tree has just let go of. /// /// Two conditions, and the second is what makes this safe. The daemon's answer /// is `collect_orphan_panes` taken across the whole machine *after* the change, /// so a pane that merely moved is still held by something and is never named — /// but a pane this window has spawned and registered and not yet placed in the /// tree is named, because at that instant nothing in the tree does hold it. Its /// `TabCreate` may still be sitting in the queue behind the op that produced /// this answer. Ending it would take down a pane the user is looking at, which /// is how a workspace with ten live tabs was lost before (#628). So a pane the /// window is showing is left alone whatever the tree says. fn hang_up_detached(cx: &mut App, client_ws: WorkspaceId, detached: Vec) { if detached.is_empty() { return; } let Some(showing) = shown_pane_ids(cx) else { log::debug!( "workspace {client_ws}: {} detached pane(s) left running — the window could not be \ read, and a pane it might still be showing is not one to end on a guess", detached.len() ); return; }; // Asked of the store rather than of the window: this is a property of the // workspace, and the store answers without borrowing an entity. let route = crate::terminal::PaneRoute::for_workspace( crate::ui::remote_workspace::pane_workspace_for(cx, client_ws).as_ref(), ); for pane in detached { if showing.contains(&pane) { continue; } if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { state.spawned.remove(&pane); } log::debug!("hanging up pane {pane}, which the tree no longer holds"); let route = route.clone(); cx.background_executor() .spawn(async move { crate::terminal::RemoteTerminal::kill_pane_on(&route, pane) }) .detach(); } } /// Enters a pane this window has just brought into existence into its census. /// /// Called from the one place both spawn routes meet — the local one builds its /// view inline, the remote one from `land_pane` — and always on the main /// thread with the pane already going into a tab in the same update, so no /// sweep can see a pane between its being noted and its being held. /// /// A pane with no owning workspace is not noted. Nothing would ever be able to /// judge it: the sweep works from a workspace's freshly pulled tree, and a pane /// belonging to no workspace has no tree to be absent from. pub(crate) fn note_spawned(cx: &mut App, owner: Option, pane: u64) { let Some(owner) = owner else { return; }; cx.default_global::() .windows .entry(owner) .or_default() .spawned .insert(pane); } /// Notes the panes a layout change has just put down, for [`sweep_parked`] to /// judge once there is a tree worth judging against. /// /// The window drops a view whenever its layout is rewritten — a delta arriving /// for a tab it had built differently, a rebuild from the tree. Dropping a view /// does not end the shell behind it, because that is also what detaching is, so /// a pane the new shape does not want goes on running with nothing to reach it. /// Nothing is ended here; see [`WsState::parked`] for why not. fn park_dropped( cx: &mut App, client_ws: WorkspaceId, before: &std::collections::HashSet, after: &std::collections::HashSet, ) { if before.is_subset(after) { return; } let dropped: Vec = before.difference(after).copied().collect(); if dropped.is_empty() { return; } if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { state.parked.extend(dropped); } } /// Settles the census against the machine, when and only when it might say /// something. /// /// The sweep needs the whole machine's tree, and most landings do not carry /// one: `desync` orders a *prime*, which pulls this workspace's mirror alone, /// and that is the landing a refused operation leads to — so the sweep that /// judged only full hydrations never ran once across three create-and-close /// cycles that stranded three shells. /// /// Rather than make every prime pull the machine, the cheap half of the /// question is asked locally first: a censused pane that some window is still /// showing is held, and while every one of them is, there is nothing to ask. /// In ordinary use that is always true and this costs one set comparison. The /// pull is paid for only once something looks stranded, which is the case where /// a shell is otherwise left running for the rest of the session. /// Every pane this window is answerable for: the ones it parked, and the ones /// it has spawned and registered but not yet placed in the tree. /// /// One definition rather than two. `settle_census` reads it to decide whether /// the tree is worth pulling; `sweep_parked` reads it to decide which panes to /// end. Built separately they can drift, and drifting means either ending a /// pane the census never weighed or pulling for one the sweep will not judge — /// so the set that answers "is this window still holding anything" has to be /// the set that answers "what is left to end". fn census_of(cx: &mut App, client_ws: WorkspaceId) -> std::collections::HashSet { cx.default_global::() .windows .get(&client_ws) .map(|s| s.parked.iter().chain(s.spawned.iter()).copied().collect()) .unwrap_or_default() } fn settle_census(cx: &mut App, client_ws: WorkspaceId) { let census = census_of(cx, client_ws); if census.is_empty() { return; } let Some(showing) = shown_pane_ids(cx) else { return; }; if census.iter().all(|pane| showing.contains(pane)) { return; } let host = WorkspaceStore::host_of(cx, client_ws); let TreeLink::Ready(client) = tree_control_for(cx, host) else { return; }; cx.spawn(async move |cx| { let pulled = cx .background_executor() .spawn(async move { machine_get(&client) }) .await; let Ok(machine) = pulled else { return; }; let tree: std::collections::HashSet = machine .workspaces .iter() .flat_map(|w| w.tabs.iter()) .flat_map(|t| t.root.pane_ids()) .collect(); cx.update(|cx| sweep_parked(cx, client_ws, &tree)); }) .detach(); } /// Ends the parked shells that a freshly pulled tree confirms nothing holds. /// /// `tree` has to be a tree this window has just pulled — the whole machine's, /// not a mirror. That is the entire point of parking: the mirrors are wrong /// often enough, and in both directions, that judging a pane against them ends /// live ones. /// /// Two conditions, and both have been seen to matter: the window is not showing /// the pane, and no workspace on the machine names it. Anything else is /// somebody's and is forgotten rather than ended. If the window cannot be read /// at all nothing is swept and the parked set is kept for the next pull — "I /// could not look" is not a reason to end a shell. fn sweep_parked(cx: &mut App, client_ws: WorkspaceId, tree: &std::collections::HashSet) { let parked: Vec = census_of(cx, client_ws).into_iter().collect(); if parked.is_empty() { return; } let Some(showing) = shown_pane_ids(cx) else { return; }; if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { state.parked.clear(); } let route = crate::terminal::PaneRoute::for_workspace( crate::ui::remote_workspace::pane_workspace_for(cx, client_ws).as_ref(), ); for pane in stranded_of(&parked, &showing, tree) { // Out of the census as well: the pane is over, and judging it again on // every later sweep would ask the daemon to end it again each time. if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { state.spawned.remove(&pane); } log::debug!("hanging up pane {pane}: the tree it was dropped from does not name it"); let route = route.clone(); cx.background_executor() .spawn(async move { crate::terminal::RemoteTerminal::kill_pane_on(&route, pane) }) .detach(); } } /// Which parked panes nothing is holding: the whole decision, with none of the /// ending. /// /// Split out because the two halves are verifiable in very different ways. The /// hang-up itself can only be watched happening — and the fuzz that watches it /// varies enormously run to run, which is how a change of mine once looked /// effective for five iterations while doing nothing at all. The rule is /// arithmetic and can simply be asserted. fn stranded_of( parked: &[u64], showing: &std::collections::HashSet, tree: &std::collections::HashSet, ) -> Vec { parked .iter() .copied() .filter(|pane| !showing.contains(pane) && !tree.contains(pane)) .collect() } fn pump(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let client = tree_control_for(cx, host); let state = cx .default_global::() .windows .entry(client_ws) .or_default(); if state.inflight || state.queue.is_empty() { return; } let client = match client { TreeLink::Ready(client) => client, TreeLink::Unserved => { desync(cx, client_ws, "the server does not serve the machine tree"); return; } TreeLink::Down => { desync(cx, client_ws, "the control link is down"); return; } }; let batch: Vec = state.queue.drain(..).collect(); state.inflight = true; cx.spawn(async move |cx| { let result = cx .background_executor() .spawn(async move { // `TabClose` and `PaneClose` answer with the panes the tree let // go of, and the shells behind them keep running until somebody // hangs them up. The daemon will not do it: the same removal is // how a pane crosses to another tab, so it cannot tell an // ending from a move. Whoever sent the op has to say. `tty7 // pane close` always has (`hang_up_removed_panes`); the window // did it only where a *person* closed something, and never for // the identical ops its own reconciliation raises. // // Collected here and acted on back on the main thread, where // what the window is still showing can be read. let mut detached: Vec = Vec::new(); for op in batch { match client.call(op.clone()) { Ok(ReplyOk::Panes(panes)) => detached.extend(panes), Ok(_) => {} Err(e) => return Err((op, e)), } } Ok(detached) }) .await; cx.update(|cx| { if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { state.inflight = false; } match result { Ok(detached) => { hang_up_detached(cx, client_ws, detached); pump(cx, client_ws) } Err((op, e)) => { log::warn!("tree operation {op:?} failed: {e}; re-pulling the tree"); if let Some(pane) = seeded_pane(&op) { // One of the ways this window strands a shell, and the // only one that announces itself. `TabCreate`, // `PaneSplit` and `PaneReplace` each name a pane this // window has *already* spawned — the shell is running // on the machine before the tree is told about it. When // the op is refused the tree never takes the pane, the // re-pull below leaves the window without the tab it // was for, and the shell goes on running with nothing // referencing it. // // It is the *minority* path, which is worth knowing // before anyone tries to fix the leak here. Driving 70 // ordinary tree operations from the CLI with a window // open left 10 orphaned shells; this line accounted for // 3 of them. The rest were spawned for a layout the // window then threw away without ever sending an op — // "dropping a superseded hydration", and a delta that // "did not apply cleanly" and forced a re-pull. Nothing // is refused on those paths, so nothing reaches here. // The same 70 operations against the daemon with no GUI // attached leak nothing at all. // // So the sweep cannot live here even setting aside the // timing: at this point the window still holds a view // for the pane and only drops it once the re-pull // lands, so hanging it up here would kill a pane that // is still on screen — and it would miss two paths out // of three anyway. It belongs after a hydration // settles, phrased against the end state rather than // against any one failure: a pane this window spawned // that neither the machine tree nor any live view holds // is stranded however it got there. It has to test the // whole tree rather than this workspace's mirror, since // a pane of another workspace on the same host is not // this window's to end. // // Until then `tty7 pane close --orphans` is the // recovery, and `tty7 pane ls --all` points at it. // Confirmed at larger scale since: 160 CLI operations // against a live window left 17, every one of them a // *live* `zsh` rather than a stale record, and the // reaper ended all 17. // // And bounded, which matters more than the total for // deciding what this costs. It is not volume that // strands them, it is closing something before the // window has finished reconciling the thing that made // it. Measured against a live window: // // tab new / split / send, paced or as fast as the // CLI will go 0 orphans // tab new then tab close, 1s apart, x4 0 orphans // tab new then tab close, no pause, x6 5 orphans // // The last one reproduced exactly — 5 of 6, three runs // in a row. A person cannot type that fast and an agent // does it by default: a loop that opens a tab per task // and closes it when the task is done strands a live // shell on nearly every iteration, which is the shape // this product is built for. // // Fixed since, and not here: see `WsState::spawned` and // `settle_census`. The window now keeps a census of // every pane it has brought into existence and settles // it against a freshly pulled machine tree after each // prime, which is exactly the landing the `desync` // below leads to. The same reproducer that stranded a // shell on six runs out of six now strands none, across // sixteen create-and-close cycles, while four unrelated // panes kept their ids and their scrollback across a // GUI restart. This arm is left saying what it always // said: it names the one path that announces itself, // and the sweep is what actually collects the pane. // // Two earlier experiments, kept because they say where // the leak is *not*. The first: parking the pane here — `park_dropped`, letting // the existing `sweep_parked` judge it after the re-pull // instead of hanging it up on the spot — is safe and // does *not* measurably help. Across four runs the share // of panes reaching this arm that were still orphaned at // the end was 33–60% without it and 43–50% with it: the // panes that get here are mostly already caught by the // sweep the layout rewrite arms. // // The second: parking the seeded panes of the queued // operations `desync` is about to discard — the nearest // of the two silent paths, and the one that looks most // tractable, since an op that never left this process // provably never filed its pane. It fires rarely (0–2 // panes across ten create/close cycles) and does not // reliably reap even those: the sweep needs a hydration // to settle, and a parked pane can outlive the window's // next few pulls. Totals were 4, 4, 5 against a baseline // of 5, 5, 5. // // What both have in common is parking at a *site*. The // leak does not live at one: the sweep had to be phrased // against the end state, over every pane this window // spawned, and it had to run on the landing the failures // actually reach. Judging only full hydrations, it never // ran once across three cycles that stranded three // shells. log::warn!( "pane {pane} was spawned for that operation and nothing holds it \ now; it will show up in `tty7 pane ls --all` as an orphan" ); } desync(cx, client_ws, "an operation was refused"); } } }); }) .detach(); } /// The pane an operation carries that this window has already spawned. /// /// These three requests are the only ones that name a pane into existence: /// the shell is running on the machine before the tree is told about it, which /// is exactly what makes a refused operation leave something behind. Every /// other request only ever moves, renames or removes panes the tree already /// knows, and refusing one of those strands nothing. fn seeded_pane(op: &ControlRequest) -> Option { match op { ControlRequest::TabCreate { pane, .. } => Some(pane.pane), ControlRequest::PaneSplit { new, .. } => Some(new.pane), ControlRequest::PaneReplace { new, .. } => Some(new.pane), _ => None, } } fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) { log::info!("resynchronizing workspace {client_ws} with its machine ({why})"); let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { return; }; state.queue.clear(); state.inflight = false; state.sync = SyncPhase::Unprimed { dirty: true, priming: true, }; state.epoch += 1; start_prime(cx, client_ws); } pub(crate) fn session_from_tree( ws: &tty7_core::core::machine::Workspace, panes: &[PaneRecord], ) -> Session { let tabs: Vec = ws .tabs .iter() .map(|tab| SessionTab { name: tab.name.clone(), tree_id: Some(tab.id), sidebar_group: tab.sidebar_group.clone().map(std::path::PathBuf::from), pane: session_pane_from_node(&tab.root, panes), }) .collect(); let active = ws .active_tab .and_then(|id| ws.tabs.iter().position(|t| t.id == id)) .unwrap_or(0); Session { active, tabs } } fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane { match node { PaneNode::Leaf { pane } => { let record = panes.iter().find(|p| p.id == *pane); let (cwd, ssh_spec, agent, shell) = match record { Some(r) => ( r.cwd.clone().map(std::path::PathBuf::from), r.ssh_spec.clone(), r.agent.clone(), r.shell.clone(), ), None => (None, None, None, None), }; SessionPane::Leaf { cwd, // The id goes down whatever `live` says. That flag is a cached // fact about another process, written by whoever last observed // the pane and reloaded from disk as `false` on every server // start — so a quiet pane that nobody has observed since reads // as dead while its shell is very much alive. Believing it here // is what threw away live sessions on a workspace switch: the // id was erased, and the restore below had nothing to attach // to, so it spawned a fresh shell over a running one. // // Attaching is the thing that actually knows. `spawn_shell_ // terminal_in` attaches when the pane is there and spawns fresh // when it is not, which is the same answer this filter was // trying to guess — except it is right. `live` stays a hint for // what to show, never the judge of what to destroy. pane_id: Some(*pane), shell, ssh_spec, agent: agent.as_ref().map(|a| a.agent), agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()), agent_launch_argv: agent.as_ref().and_then(|a| a.launch_argv.clone()), } } PaneNode::Split { axis, ratio, a, b } => SessionPane::Split { axis: match axis { TreeAxis::Horizontal => crate::core::session::SessionAxis::Horizontal, TreeAxis::Vertical => crate::core::session::SessionAxis::Vertical, }, ratio: *ratio, a: Box::new(session_pane_from_node(a, panes)), b: Box::new(session_pane_from_node(b, panes)), }, } } const HYDRATE_LINK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15); const HYDRATE_LINK_POLL: std::time::Duration = std::time::Duration::from_millis(200); pub(crate) fn hydrate_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { hydrate(cx, client_ws, Adopt::IfEmpty); } /// Hydrates a window whose on-screen tabs are already known to the caller. /// /// Reading them back with `tabs_on_screen` is illegal while the app's own /// update lease is held — gpui aborts on a read of an entity that is already /// being updated — and `switch_workspace` runs inside exactly that lease. The /// ids are the same ones the read would produce: the tabs the window shows /// right now. pub(crate) fn hydrate_window_with_tabs(cx: &mut App, client_ws: WorkspaceId, showing: Vec) { hydrate_with(cx, client_ws, Adopt::IfEmpty, showing); } /// Pulls this window's layout, then opens `path` as one more tab in it. /// /// This is what a launch carrying a directory does — Explorer's "Open in tty7", /// or `tty7 ` with no window up. Both halves are wanted: the layout the /// user left, and the folder they just double-clicked. pub(crate) fn hydrate_window_then_open( cx: &mut App, client_ws: WorkspaceId, path: std::path::PathBuf, ) { cx.default_global::() .windows .entry(client_ws) .or_default() .then_open = Some(path); hydrate(cx, client_ws, Adopt::IfEmpty); } /// Opens the folder a launch parked here, now that the layout it waited for is /// up. Does nothing for the windows — every other one — that parked nothing. fn open_parked_path(cx: &mut App, client_ws: WorkspaceId) { let Some(path) = cx .default_global::() .windows .get_mut(&client_ws) .and_then(|state| state.then_open.take()) else { return; }; let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { return; }; let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) else { return; }; let _ = handle.update(cx, move |_, window, cx| { app.update(cx, |app, cx| app.new_tab_at(path, window, cx)); }); } #[derive(Clone, Copy, PartialEq)] enum Adopt { IfEmpty, Replace, } /// The tree ids of the tabs `client_ws`'s window is showing. /// /// Empty for a workspace no window has — and for a test with no registry at /// all, which is the same answer: nothing is on screen to speak for it. fn tabs_on_screen(cx: &mut App, client_ws: WorkspaceId) -> Vec { if !cx.has_global::() { return Vec::new(); } crate::ui::windows::WindowRegistry::app_for(cx, client_ws) .and_then(|app| app.upgrade()) .map(|app| app.read(cx).tabs.iter().map(|t| t.tree_id.get()).collect()) .unwrap_or_default() } fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) { let showing = tabs_on_screen(cx, client_ws); hydrate_with(cx, client_ws, adopt, showing); } fn hydrate_with(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt, showing: Vec) { let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); let (epoch, failures) = { let state = cx .default_global::() .windows .entry(client_ws) .or_default(); state.sync = SyncPhase::Unprimed { dirty: false, priming: true, }; state.queue.clear(); state.epoch += 1; // This attempt takes over the debt; it re-records it if it fails too. state.rehydrate = None; // What the window is showing as this attempt is ordered, so a debt it // has to record can tell that layout apart from one the user builds // while the pull is out. state.owed_over = showing; if adopt == Adopt::Replace { // A `Replace` is the statement that this window no longer speaks // for its workspace, so the licence to prune goes with it — the // same move `on_preempted` makes, for the same reason. Only a pull // that lands hands it back (`settle_hydration`). // // Without this, a `Replace` that failed and was then abandoned left // a window that had never seen the layout still authorised to diff // at `SyncScope::Full`: one tab the user opened over an emptied // window became `TabClose` for every tab on the machine, deleting // the records of panes whose shells were still running (#579). The // window is left additive instead — it writes its own tabs up and // closes nothing it cannot account for. state.informed = false; } (state.epoch, state.rehydrate_attempts) }; // How many times in a row this window has already failed, which is what // decides whether another failure is news or the same news again. let level = hydration_log_level(failures, log::Level::Warn); cx.spawn(async move |cx| { let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE; let client = loop { match cx.update(|cx| tree_control_for(cx, host)) { TreeLink::Ready(client) => break Some(client), TreeLink::Unserved => { log::log!( level, "workspace {client_ws}: its machine's server does not serve the \ machine tree; opening empty" ); break None; } TreeLink::Down if std::time::Instant::now() > deadline => { log::log!( level, "workspace {client_ws}: no link to its machine; opening empty" ); break None; } TreeLink::Down => cx.background_executor().timer(HYDRATE_LINK_POLL).await, } }; let Some(client) = client else { cx.update(|cx| { // Only the attempt that still owns the window gets to speak: a // superseded one is being retried right now, and announcing an // emptiness someone else is already filling would be a lie by // the time it is read. // // A machine that answers late is normal for a remote one, and // the switcher already says so there. On this computer nothing // else would. if owe_rehydration(cx, client_ws, epoch, adopt) && adopt == Adopt::IfEmpty && host.is_local() { say_why_the_window_is_empty(cx, client_ws); } }); return; }; // Read here rather than before the spawn: `switch_workspace` orders // this pull and only then names the workspace, so at the moment this // task was created the name had not been typed in yet. let chosen = cx.update(|cx| { cx.default_global::() .windows .get(&client_ws) .and_then(|state| state.chosen_name.clone()) }); let outcome = cx .background_executor() .spawn(async move { pull_workspace(&client, machine_ws, chosen) }) .await; cx.update(|cx| finish_hydration(cx, client_ws, epoch, adopt, outcome)); }) .detach(); } /// Tells the window it opened empty because its machine never answered. /// /// An empty window is also what a window with no tabs looks like, and the retry /// that would fill it in is as quiet as the failure was — so a server one /// dialect behind reads as "tty7 lost my tabs" with nothing anywhere to say /// otherwise. This is that "otherwise", said in the window it happened to. fn say_why_the_window_is_empty(cx: &mut App, client_ws: WorkspaceId) { match cx.default_global::().windows.get_mut(&client_ws) { Some(state) if !state.said_why_empty => state.said_why_empty = true, _ => return, } let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { return; }; let _ = handle.update(cx, |_, window, cx| { window.push_notification(t(L10nKey::TreeWindowOpenedEmpty), cx); }); } /// Records that a hydration failed and still owes `client_ws` its layout, and /// arms the retry that pays it back. /// /// Nothing else recovers on its own: the window stays empty, and without this /// the next `sync_window` would push that emptiness to the machine as "close /// every tab". The debt is settled by the next sync of this window — a /// reconnect drives one through `on_link_up`, an edit in the window drives one /// through `save_session`, and [`arm_rehydrate_retry`] drives one when neither /// happens. /// /// That last driver is the load-bearing one. A pull can fail with the link /// perfectly healthy — a `MachineGet` that overran its ten seconds on a slow /// link, or a create that lost its race with `start_prime` — and then no link /// ever comes back up to notice, and an empty window has nothing to edit. The /// window sat empty until the app was restarted, with every tab and every /// shell still on the machine: "tty7 lost my session" for a request that /// needed asking twice. /// /// Returns whether the debt was taken on. A superseded attempt gets `false`: /// a newer hydration owns the window now, and this one speaks for nothing. fn owe_rehydration(cx: &mut App, client_ws: WorkspaceId, epoch: u64, adopt: Adopt) -> bool { let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { return false; }; if state.epoch != epoch { return false; } if let SyncPhase::Unprimed { priming, .. } = &mut state.sync { *priming = false; } state.rehydrate = Some(adopt); state.rehydrate_attempts = state.rehydrate_attempts.saturating_add(1); let attempts = state.rehydrate_attempts; log::log!( // Once settled this line says the same thing every thirty seconds until // the window closes, which is a fact about the machine and not an event. hydration_log_level(attempts, log::Level::Info), "workspace {client_ws}: will pull its layout again once its machine answers \ (attempt {attempts})" ); arm_rehydrate_retry(cx, client_ws, epoch, attempts); true } /// Whether the debt this retry was armed for is still the window's own. /// /// A newer epoch means another hydration took the window over while the /// backoff ran, and this retry speaks for nothing. fn still_owed(cx: &App, client_ws: WorkspaceId, epoch: u64) -> bool { cx.try_global::() .and_then(|t| t.windows.get(&client_ws)) .is_some_and(|s| s.rehydrate.is_some() && s.epoch == epoch) } /// The attempt from which the backoff no longer grows. /// /// Also the point where a window stops being a fresh failure and becomes a /// standing one, which is what [`hydration_log_level`] keys off. const REHYDRATE_SETTLED: u32 = 5; const REHYDRATE_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(30); /// The first retry is soon enough to look instant to someone watching an empty /// window; the backoff is what keeps a machine that is really unreachable from /// being asked on a loop for as long as its window stays open. fn rehydrate_backoff(attempts: u32) -> std::time::Duration { std::time::Duration::from_secs(2u64.saturating_pow(attempts.min(REHYDRATE_SETTLED))) .min(REHYDRATE_BACKOFF_CAP) } /// Steps `fresh` down to `debug` once this window's failures have stopped being /// events and become a standing condition. /// /// The first few are news: something that was working stopped. Once the backoff /// has settled at its cap the window is in a steady state — a machine that is /// simply not there — and the retry will go on failing every thirty seconds for /// as long as the window stays open. Repeating that at full volume buries /// whatever else is in the log. The retry stays exactly as persistent either /// way; only the volume drops. fn hydration_log_level(attempts: u32, fresh: log::Level) -> log::Level { if attempts >= REHYDRATE_SETTLED { log::Level::Debug } else { fresh } } /// Asks `client_ws` to sync once the backoff is up, if it still owes a pull. /// /// Deliberately routed through `sync_window` rather than straight into /// `hydrate`: that is where the rules about *whether* a window may still adopt /// the machine's layout live — a preempted workspace stays out of it, and a /// `Replace` is dropped once the user has filled the window in themselves. fn arm_rehydrate_retry(cx: &mut App, client_ws: WorkspaceId, epoch: u64, attempts: u32) { let delay = rehydrate_backoff(attempts); cx.spawn(async move |cx| { cx.background_executor().timer(delay).await; cx.update(|cx| { if !still_owed(cx, client_ws, epoch) { return; } // No window left to fill, so asking its machine now would be work // for nobody. Closing a window drops its whole `WsState` through // `forget`, debt and all, so `still_owed` above normally answers // first; this covers the window that is on its way out and has // already dropped its app. let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws) .and_then(|app| app.upgrade()) else { return; }; app.update(cx, |app, cx| sync_window(app, cx)); }); }) .detach(); } fn pull_workspace( client: &ControlClient, machine_ws: WorkspaceId, chosen: Option, ) -> io::Result<(Machine, WsMirror, Session)> { let mut machine = match layout_of(machine_get(client)?, machine_ws) { Ok(pulled) => return Ok(pulled), Err(machine) => machine, }; // The whole tree is already in hand, so the taken names can be read // straight off it rather than passed down from the main thread. let taken: Vec<&str> = machine .workspaces .iter() .filter_map(|w| w.name.as_deref()) .collect(); // A name the user typed beats a rolled one. This create and `start_prime`'s // race each other (see the `Err` arm below), so both have to offer it — // whichever wins, the workspace ends up called what was asked for. let name = chosen.unwrap_or_else(|| tty7_core::core::codename::unique(|n| taken.contains(&n))); match client.call(ControlRequest::WorkspaceCreate { name: Some(name), workspace: Some(machine_ws), }) { // The tree read a moment ago predates the workspace this call just // made, and no delta will fill it in: a client is left out of the // deltas its own ops raise. Put the created workspace into the tree // about to be installed, or the mirror holds it unnamed and the name // the machine gave it arrives later looking like a rename (#604). Ok(ReplyOk::WorkspaceTree(created)) => { machine.workspaces.retain(|w| w.id != created.id); machine.workspaces.push(*created); Ok((machine, WsMirror::default(), Session::default())) } Ok(_) => Ok((machine, WsMirror::default(), Session::default())), // Losing this create is not a failed hydration. Opening a remote // workspace runs two pulls at once — this one and `start_prime`'s — // and both create when the tree they read did not hold it yet, so the // loser is told it already exists. The workspace the create was for is // on the machine either way, and it may already hold tabs: read the // tree again and hydrate from what is really there. Treating this as a // failure left the window empty over a workspace that was fine. // // Any refusal is worth the second look, not just "already exists": what // matters is whether the workspace is there now, and the tree answers // that better than the error text does. If it still is not there, the // create's own refusal is the honest error to report — the reread // happened on its behalf and has nothing of its own to say. Err(refused) => { log::debug!( "workspace {machine_ws} could not be created ({refused}); reading the tree \ again in case something else created it first" ); match machine_get(client) { Ok(machine) => layout_of(machine, machine_ws).map_err(|_| refused), Err(_) => Err(refused), } } } } fn machine_get(client: &ControlClient) -> io::Result { match client.call(ControlRequest::MachineGet)? { ReplyOk::MachineTree(m) => Ok(*m), other => Err(io::Error::other(format!("MachineGet answered {other:?}"))), } } /// This workspace's layout as `machine` has it, or the tree handed back /// untouched when the machine does not hold the workspace at all. fn layout_of( machine: Machine, machine_ws: WorkspaceId, ) -> Result<(Machine, WsMirror, Session), Machine> { let Some(ws) = machine.workspaces.iter().find(|w| w.id == machine_ws) else { return Err(machine); }; let mirror = WsMirror { tabs: ws.tabs.clone(), active: ws.active_tab, }; let session = session_from_tree(ws, &machine.panes); Ok((machine, mirror, session)) } fn finish_hydration( cx: &mut App, client_ws: WorkspaceId, epoch: u64, adopt: Adopt, outcome: io::Result<(Machine, WsMirror, Session)>, ) { if settle_hydration(cx, client_ws, epoch, adopt, outcome) { open_parked_path(cx, client_ws); } } /// The body of [`finish_hydration`]. Returns whether this attempt settled the /// window's layout — false for one that was superseded or has to be retried, /// which are the two cases where a parked folder waits for the attempt that /// does settle it rather than opening over a layout still on its way. fn settle_hydration( cx: &mut App, client_ws: WorkspaceId, epoch: u64, adopt: Adopt, outcome: io::Result<(Machine, WsMirror, Session)>, ) -> bool { let current = cx .default_global::() .windows .get(&client_ws) .map(|s| s.epoch); if current != Some(epoch) { log::debug!("workspace {client_ws}: dropping a superseded hydration"); return false; } let (machine, mirror, session) = match outcome { Ok(pulled) => pulled, Err(e) => { let failures = cx .default_global::() .windows .get(&client_ws) .map_or(0, |s| s.rehydrate_attempts); log::log!( hydration_log_level(failures, log::Level::Warn), "could not hydrate workspace {client_ws} from its machine: {e}" ); let _ = owe_rehydration(cx, client_ws, epoch, adopt); return false; } }; let host = WorkspaceStore::host_of(cx, client_ws); // The pane ids this freshly pulled tree accounts for, across the whole // machine: what `sweep_parked` judges against, and the only account of the // machine in this file that was not assembled from deltas. Read here // because installing it below moves it. let tree_panes: std::collections::HashSet = machine .workspaces .iter() .flat_map(|w| w.tabs.iter()) .flat_map(|t| t.root.pane_ids()) .collect(); // Swept on every landing that pulled a tree, not only the ones that went // on to rebuild. The tree in hand is authoritative either way, and a pull // whose layout already matched is the common case — it is where a pane // stranded by a refused operation is sitting by the time the re-pull that // the refusal ordered comes back. Judging only rebuilds is why the sweep // never ran at all across three create-and-close cycles. let landed = 'landed: { let machine_ws = tree_workspace_id(cx, client_ws); // What the tree that is about to be installed calls this workspace, which // for a pull that had to create it is the name that create proposed. let answered = machine .workspaces .iter() .find(|w| w.id == machine_ws) .and_then(|w| w.name.clone()); crate::ui::machine_mirror::MachineMirrors::install(cx, host, machine); let name = settle_chosen_name(cx, client_ws, answered, !mirror.tabs.is_empty()); crate::ui::machine_mirror::MachineMirrors::note_workspace_name(cx, host, machine_ws, name); let machine_was_empty = mirror.tabs.is_empty(); let was_dirty = { let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { break 'landed false; }; let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); state.informed |= machine_was_empty; state.sync = SyncPhase::Primed(mirror); // The machine answered, so the next failure starts its backoff over. state.rehydrate_attempts = 0; // The machine answered, so the explanation has been overtaken by events // and a later outage deserves its own. state.said_why_empty = false; dirty }; let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws) .and_then(|app| app.upgrade()) else { break 'landed false; }; if adopt == Adopt::IfEmpty && !app.read(cx).tabs.is_empty() { // A full window over an empty tree has to write itself back, whether // or not an edit was waiting: the machine is missing tabs this window // is showing, and nothing else would ever put them there. // // Deliberately not limited to this machine. An empty tree means one of // two things and the answer is the same either way: locally the // workspace was removed under the window (`ws rm`, or another client), // and remotely the far end lost its records — a re-imaged box, a store // that was wiped. Writing the window back is what a reattach is for. // The panes it names may well be dead; the window already draws them // that way, and a tab the user can close beats a tab that silently // stops existing. if was_dirty || machine_was_empty { app.update(cx, |app, cx| sync_window(app, cx)); } break 'landed true; } if session.tabs.is_empty() && adopt == Adopt::IfEmpty { if was_dirty && let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws) .and_then(|a| a.upgrade()) { app.update(cx, |app, cx| sync_window(app, cx)); } break 'landed true; } let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { break 'landed false; }; let wanted = session.tabs.len(); // `tree_id` is not serialized, so only a session built from the tree // carries one on every tab (which is what reaches here today). The guard // below counts the tabs asked for, not the ids found: a session with tabs // and no ids must not read as "nothing was wanted". let wanted_ids: Vec = session.tabs.iter().filter_map(|t| t.tree_id).collect(); debug_assert_eq!( wanted_ids.len(), wanted, "a session rebuilt from the tree names every tab it holds" ); log::info!("rebuilding {wanted} tab(s) of workspace {client_ws} from its machine's tree"); let _ = handle.update(cx, move |_, window, cx| { app.update(cx, |app, cx| { app.adopt_workspace(client_ws, session, window, cx) }); }); let showing = tabs_on_screen(cx, client_ws); settle_rebuild(cx, client_ws, wanted, &wanted_ids, &showing); true }; // After whatever the landing did to the layout, so a pane it took back is // seen to be held. sweep_parked(cx, client_ws, &tree_panes); landed } /// What a rebuild leaves the window entitled to say, from how many tabs the /// tree asked it to put up (`wanted`), which ids those were (`wanted_ids`), /// and the tabs it is showing now (`showing`). /// /// Informed *after* the rebuild, and only if the rebuild produced something. /// /// The licence means "this window knows what belongs in this workspace", and /// `switch_workspace` / `detach_workspace` read it as permission to delete a /// workspace that has no tabs — from the machine tree and from the store /// both. Granting it before the rebuild handed that permission to a window /// whose rebuild had not happened yet, and a rebuild can produce nothing: /// `tabs_from_session` drops any tab whose panes all fail to start, which is /// what every tab does when the pane socket is unreachable. The window then /// sat there, empty and authoritative, and the next switch deleted a /// workspace with ten live tabs in it. /// /// Emptiness that came from a failure has to stay indistinguishable from not /// knowing, because that is what it is. /// /// A rebuild that produced *some* of its tabs is the same failure, one tab at /// a time, and the licence it does earn — it put a layout up, and closes and /// splits in it must reach the machine — cannot be allowed to speak for the /// tabs it did not: at `SyncScope::Full` every mirror tab missing from the /// window is a `TabClose`, and the tabs missing were exactly the ones that /// failed to rebuild, so the sync deleted them off the machine, panes and all /// (#672). They are held instead (`not_rebuilt`), out of the diff's reach until /// a later rebuild puts them up or the machine lets them go. /// /// Holding has a cost the caller should know: `diff` stops before its /// reorder pass and the active-tab op whenever anything is held, so while /// this set stands the window's tab order and active tab do not reach the /// machine — a restart restores the order and focus from before the drag. /// And nothing retries a held tab: the set is rewritten only by the next /// `settle_rebuild`, which runs only from a hydration that rebuilds, and a /// re-prime or an `Adopt::IfEmpty` hydrate on a populated window never gets /// there. A tab that fails to rebuild stays held, and holds the ordering /// with it, until the next restart. That state was already reachable — a /// pane whose remote spawn failed stays connecting for the same span, held /// the same way — so this widens a standing hole rather than opening one; a /// retry, or a way to close a held tab from the window, is separate work. /// /// The none-rebuilt guard reads the *count* asked for, not the ids found: /// `tree_id` is not serialized, so a session that reached here from disk /// would name no ids at all, and "no ids" must not read as "no tabs wanted" /// — that would grant the licence to a window that rebuilt nothing, which is /// #672 again. fn settle_rebuild( cx: &mut App, client_ws: WorkspaceId, wanted: usize, wanted_ids: &[TabId], showing: &[TabId], ) { let not_rebuilt: Vec = wanted_ids .iter() .copied() .filter(|id| !showing.contains(id)) .collect(); if !not_rebuilt.is_empty() && !showing.is_empty() { log::warn!( "workspace {client_ws}: {} of its {wanted} tab(s) could not be rebuilt; they stay on \ the machine, held out of this window's sync (tab order and active tab are not \ synced while a tab is held)", not_rebuilt.len() ); } let rebuilt = !showing.is_empty(); cx.default_global::() .windows .entry(client_ws) .or_default() .not_rebuilt = not_rebuilt; if rebuilt || wanted == 0 { mark_window_informed(cx, client_ws); } else { log::warn!( "workspace {client_ws}: none of its {wanted} tab(s) could be rebuilt; leaving the \ window uninformed so the layout is not mistaken for an empty workspace" ); } } /// Someone else removed this workspace from its machine — `tty7 ws rm`, or /// another client. /// /// With no window on it, it stops existing here too. Left in the store it /// would keep its row in the switcher and open onto nothing, which is how a /// workspace deleted from the CLI used to haunt the panel until a restart. /// /// With a window on it, the window stays: `ws rm` leaves every pane running, /// and closing the window would strand them with no way back. Pulling the /// layout again is what makes that honest — finding the workspace gone is /// exactly the case `pull_workspace` puts back under the same id, and the /// window writes its tabs to it on the way out of the hydration. fn on_workspace_deleted(cx: &mut App, client_ws: WorkspaceId) { if crate::ui::windows::WindowRegistry::window_for(cx, client_ws).is_none() { log::info!("workspace {client_ws} was deleted on its machine; forgetting it here too"); forget(cx, client_ws); crate::core::session::WorkspaceStore::remove(cx, client_ws); crate::ui::windows::refresh_menu(cx); cx.refresh_windows(); return; } log::info!( "workspace {client_ws} was deleted on its machine while a window still had it open; \ putting it back under the same id" ); hydrate(cx, client_ws, Adopt::IfEmpty); } pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: LayoutDelta) { crate::ui::machine_mirror::MachineMirrors::apply_delta(cx, host, key, &delta); let client_ws = if host.is_local() { key.parse::().ok() } else { WorkspaceStore::all(cx) .views .iter() .find(|w| { w.host .as_ref() .is_some_and(|r| r.host_id() == host && r.workspace.to_string() == key) }) .map(|w| w.id) }; let Some(client_ws) = client_ws else { return; }; if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) { on_preempted(cx, client_ws); return; } if matches!(delta, LayoutDelta::WorkspaceDeleted) { on_workspace_deleted(cx, client_ws); return; } let mirror_ok = match cx .default_global::() .windows .get_mut(&client_ws) .map(|s| &mut s.sync) { Some(SyncPhase::Primed(mirror)) => apply_to_mirror(mirror, &delta), _ => return, }; let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) else { return; }; let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { return; }; // Taken either side of the delta, from inside the update where the window // is already in hand. A delta that reshapes a tab puts down whatever the // new shape does not want, and that is where most stranded shells come // from — more than refused ops and rebuilds together. let applied = handle .update(cx, |_, window, cx| { app.update(cx, |app, cx| { let before = pane_ids_of(app, cx); let ok = app.apply_layout_delta(&delta, window, cx); (ok, before, pane_ids_of(app, cx)) }) }) .ok(); let window_ok = match applied { Some((ok, before, after)) => { park_dropped(cx, client_ws, &before, &after); ok } None => true, }; if !mirror_ok || !window_ok { log::info!( "workspace {client_ws}: delta {delta:?} did not apply cleanly; re-pulling the tree" ); resync_window_from_tree(cx, client_ws); return; } app.update(cx, |app, cx| sync_window(app, cx)); } fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { match delta { // Nothing here is about a workspace's tab list, so the mirror is // already right. `WorkspaceDeleted` never reaches this far — // `on_layout_delta` hands it to `on_workspace_deleted` and returns — // and is listed only so a new delta cannot join this arm by accident. LayoutDelta::WorkspaceCreated { .. } | LayoutDelta::WorkspaceRenamed { .. } | LayoutDelta::WorkspaceTouched { .. } | LayoutDelta::WorkspaceDeleted | LayoutDelta::PaneFacts { .. } => true, LayoutDelta::ActiveTabChanged { tab } => { mirror.active = Some(*tab); true } LayoutDelta::TabCreated { at, tab } => { mirror.tabs.retain(|t| t.id != tab.id); let at = (*at).min(mirror.tabs.len()); mirror.tabs.insert(at, tab.clone()); true } LayoutDelta::TabClosed { tab } => { let before = mirror.tabs.len(); mirror.tabs.retain(|t| t.id != *tab); if mirror.tabs.is_empty() { mirror.active = None; } mirror.tabs.len() != before } LayoutDelta::TabRenamed { tab, name } => { let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; t.name = name.clone(); true } LayoutDelta::TabRegrouped { tab, group } => { let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; t.sidebar_group = group.clone(); true } LayoutDelta::TabMoved { tab, to } => { let Some(from) = mirror.tabs.iter().position(|t| t.id == *tab) else { return false; }; let moved = mirror.tabs.remove(from); mirror.tabs.insert((*to).min(mirror.tabs.len()), moved); true } LayoutDelta::TabRestructured { tab, .. } => { let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == tab.id) else { return false; }; *t = tab.clone(); true } LayoutDelta::RatioChanged { tab, path, ratio } => { let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; match t.root.descend_mut(path) { Some(PaneNode::Split { ratio: r, .. }) => { *r = *ratio; true } _ => false, } } } } pub(crate) fn resync_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { hydrate(cx, client_ws, Adopt::Replace); } /// Records the daemon process a link is talking to, answering "did the server /// behind this link just become a *different* process?". /// /// `seen` is the instance last recorded for this link, empty when unknown. A /// first sighting is not a restart (there is nothing on screen to be wrong /// about yet), and an empty instance means the server predates the field — /// it neither reports nor overwrites what was seen before. Shared by the /// remote path (`remote_workspace::server_restarted`, keyed per host) and the /// local link (single daemon), which needs the same comparison to notice the /// daemon it lost came back as another process (#553). pub(crate) fn note_instance(seen: &mut String, instance: &str) -> bool { if instance.is_empty() { return false; } let before = std::mem::replace(seen, instance.to_string()); if !before.is_empty() && before != instance { log::info!( "the tty7-server on this machine is a new process ({before} → {instance}); \ its panes are gone" ); return true; } false } /// Drops the link to the local daemon and rebuilds every local window from the /// machine tree, for a caller that killed that daemon itself (#553). /// /// The invalidate half comes first on purpose: the link still holds the client /// that pointed at the server that is now gone, and a pull sent down it dies on /// a dead socket before the reader notices. With it dropped, `hydrate` waits for /// the reconnect `LocalLink::tick` is already driving and pulls the layout the /// daemon actually has. /// /// Only for the caller that has no live link left. One that just handshaked a /// *new* daemon calls [`resync_local_windows_from_tree`] with that link in hand: /// dropping it there would throw away a working link and make every window wait /// out another connect for no reason. pub(crate) fn resync_after_local_daemon_change(cx: &mut App) { crate::ui::local_link::LocalLink::invalidate(cx); resync_local_windows_from_tree(cx); } /// Rebuilds every local window from the machine tree, because the daemon behind /// the local link became a different process — it died and the reconnect found a /// new one, whose registry knows nothing about the panes on screen (#553). /// /// Remote windows are left alone: their own link says when their machine's /// server changed, and this one speaks for this computer only. /// /// When no daemon answers the pull, the windows owe a rehydration instead /// (`owe_rehydration`) — which is also the guard that keeps a window emptied by /// a failed pull from being pushed back up as "close every tab". pub(crate) fn resync_local_windows_from_tree(cx: &mut App) { for (workspace, _) in crate::ui::windows::WindowRegistry::open_windows(cx) { if WorkspaceStore::host_of(cx, workspace) != HostId::LOCAL { continue; } resync_window_from_tree(cx, workspace); } } impl Tty7App { pub(crate) fn apply_layout_delta( &mut self, delta: &LayoutDelta, window: &mut gpui::Window, cx: &mut gpui::Context, ) -> bool { let index_of = |tabs: &[crate::ui::app::Tab], id: TabId| { tabs.iter().position(|t| t.tree_id.get() == id) }; let applied = match delta { LayoutDelta::WorkspaceCreated { .. } | LayoutDelta::WorkspaceTouched { .. } | LayoutDelta::WorkspaceRenamed { .. } | LayoutDelta::PaneFacts { .. } => true, // Unreachable: `on_layout_delta` hands a deletion to // `on_workspace_deleted` and returns before any window is asked. A // deletion is about whether this workspace still exists here at // all, which is not a question one window's tab list can answer. LayoutDelta::WorkspaceDeleted => true, LayoutDelta::ActiveTabChanged { tab } => { if let Some(index) = index_of(&self.tabs, *tab) { self.activate_from_delta(index, window, cx); } true } LayoutDelta::TabCreated { at, tab } => { self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx) } LayoutDelta::TabClosed { tab } => { if let Some(index) = index_of(&self.tabs, *tab) { let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); self.tabs.remove(index); self.active = active_id .and_then(|id| index_of(&self.tabs, id)) .unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1))); self.maximized = None; self.focus_active(window, cx); } true } LayoutDelta::TabRenamed { tab, name } => { if let Some(index) = index_of(&self.tabs, *tab) { self.tabs[index].name = name.clone(); } true } LayoutDelta::TabRegrouped { tab, group } => { if let Some(index) = index_of(&self.tabs, *tab) { *self.tabs[index].sidebar_group.borrow_mut() = group.clone().map(std::path::PathBuf::from); } true } LayoutDelta::TabMoved { tab, to } => { if let Some(from) = index_of(&self.tabs, *tab) { let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); let moved = self.tabs.remove(from); self.tabs.insert((*to).min(self.tabs.len()), moved); if let Some(id) = active_id && let Some(index) = index_of(&self.tabs, id) { self.active = index; } } true } LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) { Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), None => false, }, LayoutDelta::RatioChanged { tab, path, ratio } => { if let Some(index) = index_of(&self.tabs, *tab) { set_gui_ratio(&mut self.tabs[index].pane, path, *ratio) } else { true } } }; cx.notify(); applied } fn activate_from_delta( &mut self, index: usize, window: &mut gpui::Window, cx: &mut gpui::Context, ) { if self.active == index { return; } self.maximized = None; self.active = index; self.focus_active(window, cx); } fn insert_tab_from_tree( &mut self, at: usize, tab: &TreeTab, window: &mut gpui::Window, cx: &mut gpui::Context, ) -> bool { if self.tabs.iter().any(|t| t.tree_id.get() == tab.id) { return true; } let mut existing = HashMap::new(); let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else { return false; }; let gui = crate::ui::app::Tab::from_tree(tab, pane); self.tabs.insert(at, gui); if self.active >= at && self.tabs.len() > 1 { self.active += 1; } true } fn rebuild_tab_from_tree( &mut self, index: usize, tab: &TreeTab, window: &mut gpui::Window, cx: &mut gpui::Context, ) -> bool { let remote = WorkspaceStore::all(cx) .get(self.workspace) .is_some_and(|w| w.is_remote()); let mut existing: HashMap = HashMap::new(); let mut ssh_slots: Vec = Vec::new(); for slot in self.tabs[index].pane.leaves() { let id = match &slot { PaneSlot::Ready(view) if remote && view.read(cx).ssh_spec().is_some() => { ssh_slots.push(slot); continue; } PaneSlot::Ready(view) => Some(view.read(cx).pane_id), PaneSlot::Connecting(pending) => pending.read(cx).spawn.restore_pane, }; if let Some(id) = id { existing.insert(id, slot); } } let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else { return false; }; let pane = ssh_slots.into_iter().fold(pane, |tree, slot| { Pane::split_node(gpui::Axis::Horizontal, 0.5, tree, Pane::Leaf(slot)) }); let gui = &mut self.tabs[index]; gui.pane = pane; gui.name = tab.name.clone(); *gui.sidebar_group.borrow_mut() = tab.sidebar_group.clone().map(std::path::PathBuf::from); self.maximized = None; true } fn build_pane_from_tree( &self, node: &PaneNode, existing: &mut HashMap, window: &mut gpui::Window, cx: &mut gpui::Context, ) -> Option { match node { PaneNode::Leaf { pane } => { if let Some(slot) = existing.remove(pane) { return Some(Pane::Leaf(slot)); } match crate::ui::app::new_terminal( self.window_workspace(cx), Some(self.workspace), self.font_size, None, Some(*pane), None, window, cx, ) { Ok(slot) => Some(Pane::Leaf(slot)), Err(e) => { log::warn!("could not attach pane {pane} from a delta: {e}"); None } } } PaneNode::Split { axis, ratio, a, b } => { let left = self.build_pane_from_tree(a, existing, window, cx); let right = self.build_pane_from_tree(b, existing, window, cx); match (left, right) { (Some(a), Some(b)) => Some(Pane::split_node( match axis { TreeAxis::Horizontal => gpui::Axis::Horizontal, TreeAxis::Vertical => gpui::Axis::Vertical, }, *ratio, a, b, )), (one, other) => one.or(other), } } } } } fn set_gui_ratio(pane: &mut Pane, path: &[Side], ratio: f32) -> bool { match path.split_first() { None => match pane { Pane::Split { ratio: cell, .. } => { cell.set(ratio.clamp(0.05, 0.95)); true } _ => false, }, Some((side, rest)) => match pane { Pane::Split { a, b, .. } => match side { Side::A => set_gui_ratio(a, rest, ratio), Side::B => set_gui_ratio(b, rest, ratio), }, _ => false, }, } } #[cfg(test)] mod tests { use super::*; /// The three conditions that have to agree before a shell is ended, each /// of which has been seen to matter. /// /// Asserted rather than watched: the sweep itself can only be observed /// happening, and the fuzz that observes it swings from 50 operations a /// run to 5, which is how an earlier change of mine looked effective for /// five iterations while doing nothing at all. #[test] fn only_a_pane_nobody_holds_is_ended() { let set = |ids: &[u64]| { ids.iter() .copied() .collect::>() }; // Held by a window, by the tree, by both, by neither. assert_eq!( stranded_of(&[1, 2, 3, 4], &set(&[1, 3]), &set(&[2, 3])), vec![4], "only the pane no window shows and no workspace names is stranded" ); // The window's word outweighs the tree's silence: a pane it has // spawned and not yet placed is exactly what the tree cannot see. assert_eq!( stranded_of(&[7], &set(&[7]), &set(&[])), Vec::::new(), "a pane on screen is never ended, whatever the tree says" ); // And the tree's word outweighs the window's: a tab that failed to // rebuild leaves its panes in the tree and off the screen. assert_eq!( stranded_of(&[8], &set(&[]), &set(&[8])), Vec::::new(), "a pane the tree still names is somebody's, and comes back on attach" ); assert!(stranded_of(&[], &set(&[1]), &set(&[2])).is_empty()); } /// The census holds every pane this window made, and only those it could /// ever answer for. /// /// The rule above is old and was never the problem; what kept the leak /// alive was that nothing put the stranded panes in front of it. A pane /// with no owning workspace is deliberately left out: the judgement is /// made against a workspace's freshly pulled machine tree, and a pane /// belonging to no workspace has no tree to be absent from, so it would be /// stranded by definition on every sweep. #[gpui::test] fn the_census_holds_the_panes_this_window_made(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); note_spawned(cx, Some(ws), 7); note_spawned(cx, Some(ws), 9); note_spawned(cx, Some(ws), 7); note_spawned(cx, None, 11); let census = cx .default_global::() .windows .get(&ws) .map(|s| s.spawned.clone()) .unwrap_or_default(); assert_eq!( census, [7, 9] .into_iter() .collect::>(), "every pane the window made, counted once" ); let other = WorkspaceId::new(); assert!( !cx.default_global::().windows.contains_key(&other), "a pane with no workspace files nothing anywhere" ); // Through `census_of`, which is what actually decides. Reading // `spawned` straight off the state proves it was filled in and // nothing more: dropping the `spawned` half of the census left // every assertion above passing, while a pane spawned and not yet // in the tree went back to being invisible to the sweep — the // whole of the leak this set exists to close. let counted = census_of(cx, ws); for pane in [7, 9] { assert!( counted.contains(&pane), "pane {pane} was recorded but the census does not weigh it: {counted:?}" ); } // And a parked pane is in it too, so neither half can be dropped // without this failing. cx.default_global::() .windows .entry(ws) .or_default() .parked .insert(21); let counted = census_of(cx, ws); assert!( counted.contains(&21) && counted.contains(&7), "the census is the parked panes and the spawned ones together: {counted:?}" ); }); } /// Pins which requests carry an already-spawned pane, because that is the /// set a refused operation can strand a shell from. A new request that /// names a pane into existence has to be added here too, or its refusal /// leaks silently. #[test] fn only_the_requests_that_spawn_a_pane_report_a_seed() { let ws = WorkspaceId::new(); let seed = |pane| PaneSeed { pane, cwd: None, ssh_spec: None, agent: None, shell: None, }; assert_eq!( seeded_pane(&ControlRequest::TabCreate { workspace: ws, at: None, pane: seed(7), tab: None, }), Some(7) ); assert_eq!( seeded_pane(&ControlRequest::PaneReplace { workspace: ws, old: 7, new: seed(8), }), Some(8) ); assert_eq!( seeded_pane(&ControlRequest::PaneSplit { workspace: ws, pane: 7, axis: TreeAxis::Vertical, ratio: 0.5, new: seed(9), first: false, }), Some(9) ); // Removing or renaming names no new pane, so a refusal strands nothing. assert_eq!( seeded_pane(&ControlRequest::TabClose { workspace: ws, tab: TabId::new(), }), None ); assert_eq!( seeded_pane(&ControlRequest::PaneClose { workspace: ws, pane: 7, }), None ); } #[test] fn note_instance_reports_only_a_real_change() { let mut seen = String::new(); assert!( !note_instance(&mut seen, "abc"), "a first sighting is not a restart" ); assert!( !note_instance(&mut seen, "abc"), "the same process is not a restart" ); assert!(note_instance(&mut seen, "def"), "a new process is"); assert!(!note_instance(&mut seen, "def")); } #[test] fn note_instance_ignores_a_server_that_predates_the_field() { let mut seen = String::from("abc"); assert!( !note_instance(&mut seen, ""), "an unknown instance is never a restart" ); assert_eq!(seen, "abc", "and it must not overwrite what was seen"); let mut fresh = String::new(); assert!(!note_instance(&mut fresh, "")); assert!( !note_instance(&mut fresh, "abc"), "the first real instance after an unknown one is a first sighting" ); } /// A name typed into the create form is for a workspace the machine has /// not been told to make yet. Sending it as a rename is sending it into a /// `NotFound`, which `unsendable` swallows, and the create that follows /// then names the workspace whatever it rolled — the user's name lost to a /// generated one every time (#618). #[gpui::test] fn a_typed_name_waits_for_the_create_rather_than_racing_it(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); cx.default_global::() .windows .entry(ws) .or_default(); name_new_workspace(cx, ws, "deploy".into()); assert_eq!( cx.default_global::().windows[&ws] .chosen_name .as_deref(), Some("deploy"), "the name rides along with the create instead of chasing it" ); }); } /// `start_prime` spends the typed name on the create, so a machine that /// answers with it has settled the matter. #[gpui::test] fn the_machine_reading_back_the_typed_name_settles_it(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let (ws, view) = primed_window(cx, Some("deploy")); let epoch = cx.default_global::().windows[&ws].epoch; finish_prime( cx, ws, epoch, Ok((WsMirror::default(), Some("deploy".into()))), ); assert!( cx.default_global::().windows[&ws] .chosen_name .is_none(), "a name the machine has confirmed is not still owed" ); assert_eq!( crate::ui::machine_mirror::display_name(cx, &view).as_deref(), Some("deploy"), "and it is what the window shows" ); }); } /// The other branch of `pull_or_create`: the workspace was already on the /// machine, so the pull answered and the create never ran — nobody was ever /// offered the typed name. It has to go out as a rename, and it has to beat /// the name the pull came back with, which #604 wired straight to the chip. #[gpui::test] fn a_workspace_the_machine_already_had_still_takes_the_typed_name( cx: &mut gpui::TestAppContext, ) { cx.update(|cx| { let (ws, view) = primed_window(cx, Some("deploy")); let epoch = cx.default_global::().windows[&ws].epoch; finish_prime( cx, ws, epoch, Ok((WsMirror::default(), Some("keen-marten".into()))), ); assert!( cx.default_global::().windows[&ws] .chosen_name .is_none(), "said once, not on every later pull" ); assert_eq!( crate::ui::machine_mirror::display_name(cx, &view).as_deref(), Some("deploy"), "the name the user typed outranks the one the pull answered with" ); }); } /// The create that actually runs when a window switches workspaces is /// `pull_workspace`'s, not `start_prime`'s — `hydrate` is what /// `switch_workspace` orders, and it rolled its own codename with no idea a /// name had been typed. Whatever the tree comes back saying, the typed name /// is what the workspace ends up called. #[gpui::test] fn a_hydrating_pull_settles_the_typed_name_too(cx: &mut gpui::TestAppContext) { cx.update(|cx| { crate::ui::windows::WindowRegistry::init(cx); let (ws, view) = primed_window(cx, Some("deploy")); let epoch = cx.default_global::().windows[&ws].epoch; let pulled = Machine { workspaces: vec![tty7_core::core::machine::Workspace { id: ws, name: Some("keen-marten".into()), ..Default::default() }], panes: Vec::new(), }; settle_hydration( cx, ws, epoch, Adopt::IfEmpty, Ok((pulled, WsMirror::default(), Session::default())), ); assert_eq!( crate::ui::machine_mirror::display_name(cx, &view).as_deref(), Some("deploy"), "the name the user typed, not the codename the pull rolled" ); assert!( cx.default_global::().windows[&ws] .chosen_name .is_none(), "and it is owed only once" ); }); } /// #716: opening a workspace that another client already filled must not /// rename it. /// /// The typed-name arbitration above exists for a create that never ran /// because this window's *own* sibling create won the race. A workspace /// that already holds tabs did not come from either of them — it belongs /// to whoever built it — and the report is what that looked like from the /// other end: a machine's workspace of nineteen live panes came back named /// after the connecting client's local user, because the connecting side /// spent a codename it had rolled for a workspace it thought it was /// making. /// /// The name is still consumed. It was owed once, and adopting a workspace /// is how it stops being owed — leaving it parked would only fire the /// rename at the next pull instead. #[gpui::test] fn a_populated_workspace_keeps_the_name_its_own_machine_gave_it(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let (ws, view) = primed_window(cx, Some("sher1")); let epoch = cx.default_global::().windows[&ws].epoch; finish_prime( cx, ws, epoch, Ok(( WsMirror { tabs: vec![TreeTab::leaf(7), TreeTab::leaf(11)], active: None, }, Some("alisher-work".into()), )), ); assert_eq!( crate::ui::machine_mirror::display_name(cx, &view).as_deref(), Some("alisher-work"), "a workspace with tabs in it was not created here, so it keeps its own name" ); assert!( cx.default_global::().windows[&ws] .chosen_name .is_none(), "and the name stops being owed either way, so no later pull fires the rename" ); }); } /// A window with no name owed reads whatever the machine says, which is /// the whole of #604 and must survive the arbitration above. #[gpui::test] fn a_window_owing_no_name_still_reads_the_machines(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let (ws, view) = primed_window(cx, None); let epoch = cx.default_global::().windows[&ws].epoch; finish_prime( cx, ws, epoch, Ok((WsMirror::default(), Some("keen-marten".into()))), ); assert_eq!( crate::ui::machine_mirror::display_name(cx, &view).as_deref(), Some("keen-marten") ); }); } /// A window mid-prime with `chosen` parked on it, and a machine this client /// has pulled, so the mirror has somewhere to write the name. fn primed_window( cx: &mut App, chosen: Option<&str>, ) -> (WorkspaceId, crate::core::session::WindowView) { tty7_core::core::config::set_config_dir( std::env::temp_dir().join(format!("tty7-chosen-name-{}", std::process::id())), ); let view = crate::core::session::WindowView::default(); let ws = view.id; WorkspaceStore::install_for_test( cx, crate::core::session::WindowViews { views: vec![view.clone()], active: Some(ws), }, ); crate::ui::machine_mirror::MachineMirrors::install(cx, HostId::LOCAL, Machine::default()); let state = cx .default_global::() .windows .entry(ws) .or_default(); state.sync = SyncPhase::Unprimed { dirty: false, priming: true, }; state.chosen_name = chosen.map(str::to_string); (ws, view) } #[cfg(unix)] #[test] fn a_peer_without_the_machine_tree_bit_classifies_as_unserved() { use tty7_core::daemon::control::ControlHello; use tty7_core::host::local::LocalHost; use tty7_core::host::server::{Services, serve_with}; let connect = |services: Services| { let (server, client) = std::os::unix::net::UnixStream::pair().unwrap(); std::thread::spawn(move || { let _ = serve_with(server, LocalHost::new(), services); }); let hello = ControlHello::host_rpc("test-token", "test-host"); Arc::new( tty7_core::daemon::control::ControlClient::over_unix( client, &hello, Box::new(|_| {}), ) .unwrap(), ) }; let treeless = connect(Services::none()); assert!(matches!( classify_tree_link(Some(treeless)), TreeLink::Unserved )); let dir = std::env::temp_dir().join(format!("tty7-treelink-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let store = tty7_core::core::machine::MachineStore::open( dir.join(tty7_core::core::machine::MACHINE_FILE), ); let serving = connect(Services::with_machine(store)); assert!(matches!( classify_tree_link(Some(serving)), TreeLink::Ready(_) )); assert!(matches!(classify_tree_link(None), TreeLink::Down)); let _ = std::fs::remove_dir_all(&dir); } #[gpui::test] fn a_parked_folder_outlives_the_pull_that_has_to_be_retried(cx: &mut gpui::TestAppContext) { // The folder an Explorer launch asked for is opened by whichever // attempt finally lands, so re-entering `hydrate` — which is how every // retry gets here — must not reset it along with the rest of the pull // state. Losing it would mean the double-clicked folder never opens. cx.update(|cx| { let ws = WorkspaceId::new(); let path = std::path::PathBuf::from("/tmp/from-explorer"); crate::core::session::WorkspaceStore::install_for_test( cx, crate::core::session::WindowViews::default(), ); crate::ui::windows::WindowRegistry::init(cx); hydrate_window_then_open(cx, ws, path.clone()); assert_eq!( cx.default_global::().windows[&ws] .then_open .as_ref(), Some(&path), "the request must be parked, not opened over a layout still in flight" ); hydrate_with(cx, ws, Adopt::IfEmpty, Vec::new()); assert_eq!( cx.default_global::().windows[&ws] .then_open .as_ref(), Some(&path), "a retry must still owe the folder" ); // With no window to put it in there is nothing to open, and the // request must not survive to surface in some unrelated window. open_parked_path(cx, ws); assert!( cx.default_global::().windows[&ws] .then_open .is_none() ); }); } #[gpui::test] fn preemption_drops_the_mirror_the_queue_and_the_informed_licence( cx: &mut gpui::TestAppContext, ) { cx.update(|cx| { let ws = WorkspaceId::new(); { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.sync = SyncPhase::Primed(WsMirror::default()); state.informed = true; state.queue.push_back(ControlRequest::Ping); } on_preempted(cx, ws); let state = &cx.default_global::().windows[&ws]; assert!(matches!( state.sync, SyncPhase::Unprimed { dirty: false, priming: false, } )); assert!( state.queue.is_empty(), "queued ops belong to the lost session" ); assert!( !state.informed, "the licence to prune must not survive a takeover" ); }); } /// The destructive half of a deletion. It erases state only this client /// holds — geometry, the label, a remote binding — so the fence in front of /// it ("no window is showing this workspace") is the whole safety of it. /// /// The other half needs a live `Tty7App` in a real window to reach, so it /// is not tested here; what it does is hydrate, which the hydration tests /// cover, and it touches neither the store nor the registry. #[gpui::test] fn a_deletion_nothing_has_open_forgets_the_workspace_here_too(cx: &mut gpui::TestAppContext) { use crate::core::session::{WindowView, WindowViews}; cx.update(|cx| { // Removing a workspace saves the views, and a test has no business // writing the real ones. tty7_core::core::config::set_config_dir( std::env::temp_dir().join(format!("tty7-deleted-test-{}", std::process::id())), ); crate::ui::windows::WindowRegistry::init(cx); let deleted = WindowView::default(); let gone = deleted.id; let untouched = WindowView::default(); let survivor = untouched.id; WorkspaceStore::install_for_test( cx, WindowViews { views: vec![deleted, untouched], active: Some(gone), }, ); cx.default_global::() .windows .entry(gone) .or_default() .sync = SyncPhase::Primed(WsMirror::default()); on_workspace_deleted(cx, gone); assert!( WorkspaceStore::all(cx).get(gone).is_none(), "a row that opens onto nothing is worse than no row at all" ); assert_eq!( WorkspaceStore::all(cx).active, None, "the active workspace cannot be one that no longer exists" ); assert!( WorkspaceStore::all(cx).get(survivor).is_some(), "a deletion is about one workspace, not about the store" ); assert!( !cx.default_global::().windows.contains_key(&gone), "its sync state has nothing left to be about" ); }); } /// The rule that stops a failed rebuild from being read as "empty". /// /// A window with no tabs may delete its workspace outright — tree and store /// both — so the two ways of having no tabs must not look alike. Genuinely /// empty is a reason; "the panes would not start" is not, and it is what /// every tab looks like when the pane socket has gone away. #[gpui::test] fn only_a_mirror_that_agrees_lets_an_empty_window_delete_its_workspace( cx: &mut gpui::TestAppContext, ) { cx.update(|cx| { let ws = WorkspaceId::new(); let set = |cx: &mut App, informed: bool, sync: SyncPhase| { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.informed = informed; state.sync = sync; }; let unprimed = || SyncPhase::Unprimed { dirty: false, priming: false, }; let primed_with = |tabs: Vec| SyncPhase::Primed(WsMirror { tabs, active: None }); let a_tab = || TreeTab { id: TabId::new(), name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, }; assert!( !workspace_is_disposable(cx, WorkspaceId::new()), "a workspace nothing is tracking is not a workspace to delete" ); set(cx, true, unprimed()); assert!( !workspace_is_disposable(cx, ws), "an unpulled mirror knows nothing, and not knowing must never authorize this" ); set(cx, true, primed_with(vec![a_tab()])); assert!( !workspace_is_disposable(cx, ws), "this is the regression: the window came up empty because the rebuild failed, \ while the machine still held the tabs. Deleting here destroyed them." ); set(cx, false, primed_with(vec![])); assert!( !workspace_is_disposable(cx, ws), "a window that never put up a layout does not get to say what belongs here" ); set(cx, true, primed_with(vec![])); assert!( workspace_is_disposable(cx, ws), "informed, and the machine agrees it holds nothing — the one case that is" ); }); } /// #554: Restart Server empties the window *before* it tries the handoff, /// and a refused handoff leaves the daemon exactly as it was, still serving /// every pane the window just dropped. /// /// What makes that recoverable is the window giving up its claim to speak /// for the machine the moment the failure lands. An emptied window that is /// still `informed` over a `Primed` mirror is the dangerous shape: the next /// sync diffs it into "close every tab", and closing the window authorizes /// a `WorkspaceRemove` outright — the shells stay alive, but their records /// go, and nothing tree-driven can ever reach them again. /// /// Tested on `resync_window_from_tree`, which is the per-window step both /// [`resync_after_local_daemon_change`] and [`resync_local_windows_from_tree`] /// do their work through — the pair above it only decides whether the link /// is dropped first and which windows are walked, neither of which is what /// keeps the emptied window quiet. #[gpui::test] fn a_failed_daemon_change_stops_the_emptied_window_speaking_for_the_machine( cx: &mut gpui::TestAppContext, ) { cx.update(|cx| { let ws = WorkspaceId::new(); let tab = TabId::new(); { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.informed = true; // The mirror the emptied window would have been diffed against, // already drained the way `save_session` drains it on the way // out of a window that has no tabs left. state.sync = SyncPhase::Primed(WsMirror { tabs: vec![], active: None, }); state .queue .push_back(ControlRequest::TabClose { workspace: ws, tab }); } assert!( workspace_is_disposable(cx, ws), "the shape #554 leaves behind: an emptied window that still speaks for a \ machine holding live panes, and may delete the workspace off it" ); resync_window_from_tree(cx, ws); assert!( !workspace_is_disposable(cx, ws), "this is the regression: closing the window after a refused handoff sent \ WorkspaceRemove and took the whole workspace, live panes and all" ); let state = &cx.default_global::().windows[&ws]; assert!( matches!(state.sync, SyncPhase::Unprimed { .. }), "the mirror the emptied window would diff into 'close every tab' has to be \ dropped, not carried into the next sync" ); assert!( state.queue.is_empty(), "operations queued from the emptied window speak for a layout that is being \ pulled again; sending them would close the tabs it is pulling" ); }); } #[gpui::test] fn a_hydration_that_died_on_a_stale_link_is_owed_back(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); let epoch = { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.sync = SyncPhase::Unprimed { dirty: false, priming: true, }; state.epoch }; owe_rehydration(cx, ws, epoch, Adopt::Replace); let state = &cx.default_global::().windows[&ws]; assert!( matches!(state.sync, SyncPhase::Unprimed { priming: false, .. }), "the attempt is over; another one must be able to start" ); assert!( state.rehydrate.is_some(), "dropping the failure here is what left the window on the home page" ); // A newer attempt has already taken over — the loser must not // re-arm a retry behind its back. { let state = cx .default_global::() .windows .get_mut(&ws) .unwrap(); state.rehydrate = None; state.epoch += 1; } owe_rehydration(cx, ws, epoch, Adopt::Replace); assert!( cx.default_global::().windows[&ws] .rehydrate .is_none() ); }); } /// The debt an owed pull records is worth nothing without something that /// pays it. A pull can fail with the link up and healthy — a `MachineGet` /// past its deadline on a slow link, a create that lost its race — and /// then no reconnect ever happens to notice, and an empty window has no /// edit in it to drive a sync. The window sat there empty, with every tab /// still on the machine, until the app was restarted. #[gpui::test] fn an_owed_pull_is_retried_until_it_is_paid_or_superseded(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); let epoch = cx .default_global::() .windows .entry(ws) .or_default() .epoch; owe_rehydration(cx, ws, epoch, Adopt::IfEmpty); assert!( still_owed(cx, ws, epoch), "the retry armed for this debt must still recognise it" ); // Paid: the pull landed, so the retry that is still in flight has // to stand down rather than replay the machine over the window. cx.default_global::() .windows .get_mut(&ws) .expect("owed above") .rehydrate = None; assert!(!still_owed(cx, ws, epoch)); // Superseded: a newer hydration owns the window now. let state = cx .default_global::() .windows .get_mut(&ws) .expect("owed above"); state.rehydrate = Some(Adopt::IfEmpty); state.epoch += 1; assert!(!still_owed(cx, ws, epoch)); assert!(still_owed(cx, ws, epoch + 1)); }); } #[test] fn the_retry_backs_off_and_settles_at_a_cap() { let secs = |n| rehydrate_backoff(n).as_secs(); assert_eq!(secs(1), 2, "the first retry is prompt: a window is empty"); assert!( secs(1) < secs(2) && secs(2) < secs(3), "a machine that keeps refusing must be asked less often, not more" ); assert_eq!(secs(REHYDRATE_SETTLED), 30); assert_eq!( secs(50), 30, "a window left open on an unreachable machine settles at the cap" ); } /// Once the backoff stops growing the same failure repeats every thirty /// seconds for as long as the window stays open. Reporting each one at full /// volume turns one unreachable machine into a log nobody can read past. #[test] fn a_standing_failure_stops_shouting_once_the_backoff_settles() { assert_eq!( hydration_log_level(1, log::Level::Warn), log::Level::Warn, "the first failures are news and must stay news" ); assert_eq!( hydration_log_level(REHYDRATE_SETTLED, log::Level::Warn), log::Level::Debug ); assert_eq!( hydration_log_level(REHYDRATE_SETTLED, log::Level::Info), log::Level::Debug, "the step down is to debug from wherever it started, not to warn" ); } /// #604: the answer to the create this window asked for is the only place /// it will ever hear the workspace's name — a client is left out of the /// deltas its own ops raise — so priming has to hand that name to the /// mirror. Dropping it left the window showing an unnamed workspace, and /// the name it had had all along arrived with the next full pull looking /// like a rename. #[gpui::test] fn priming_teaches_the_mirror_the_name_the_machine_gave_the_workspace( cx: &mut gpui::TestAppContext, ) { cx.update(|cx| { tty7_core::core::config::set_config_dir( std::env::temp_dir().join(format!("tty7-prime-name-{}", std::process::id())), ); let view = crate::core::session::WindowView::default(); let ws = view.id; WorkspaceStore::install_for_test( cx, crate::core::session::WindowViews { views: vec![view.clone()], active: Some(ws), }, ); // A machine this client has pulled, from before the workspace it is // about to create existed on it. crate::ui::machine_mirror::MachineMirrors::install( cx, HostId::LOCAL, Machine::default(), ); cx.default_global::() .windows .entry(ws) .or_default() .sync = SyncPhase::Unprimed { dirty: false, priming: true, }; let epoch = cx.default_global::().windows[&ws].epoch; finish_prime( cx, ws, epoch, Ok((WsMirror::default(), Some("keen-marten".to_string()))), ); assert_eq!( crate::ui::machine_mirror::display_name(cx, &view).as_deref(), Some("keen-marten"), "the window knows what its workspace is called as soon as it exists" ); }); } /// The count paces the retry, so it has to mean "failures in a row". Left /// standing after the run ends, it makes the next *first* failure wait the /// cap on an outage that was already over. #[gpui::test] fn the_backoff_count_ends_with_the_run_of_failures(cx: &mut gpui::TestAppContext) { cx.update(|cx| { tty7_core::core::config::set_config_dir( std::env::temp_dir().join(format!("tty7-backoff-count-{}", std::process::id())), ); let view = crate::core::session::WindowView::default(); let ws = view.id; WorkspaceStore::install_for_test( cx, crate::core::session::WindowViews { views: vec![view], active: Some(ws), }, ); let unprimed = |cx: &mut App| { cx.default_global::() .windows .entry(ws) .or_default() .sync = SyncPhase::Unprimed { dirty: false, priming: true, }; }; let attempts = |cx: &mut App| cx.default_global::().windows[&ws].rehydrate_attempts; unprimed(cx); let epoch = cx.default_global::().windows[&ws].epoch; for expected in 1..=3 { unprimed(cx); owe_rehydration(cx, ws, epoch, Adopt::IfEmpty); assert_eq!( attempts(cx), expected, "each failure in the run paces the next" ); } // The machine answered. Whatever it was, it is over. unprimed(cx); finish_prime(cx, ws, epoch, Ok((WsMirror::default(), None))); assert_eq!( attempts(cx), 0, "a prime landing is the machine answering, which is the whole question" ); // Abandoned rather than paid: the user filled the window in // themselves, so the `Replace` is dropped — and the run is over too. { let state = cx .default_global::() .windows .get_mut(&ws) .unwrap(); state.rehydrate = Some(Adopt::Replace); state.owed_over = Vec::new(); state.rehydrate_attempts = 4; } assert!(take_rehydrate(cx, ws, &[TabId::new()]).is_none()); assert_eq!( attempts(cx), 0, "a debt nobody owes any more cannot go on pacing the next one" ); }); } /// The retry fires on a timer, so the window it was armed for can be gone /// by the time it runs. It has to notice and stand down — and leave the /// debt where it is, because a window that is not there is not one that /// has been paid. #[gpui::test] async fn a_retry_that_finds_no_window_stands_down(cx: &mut gpui::TestAppContext) { let ws = cx.update(|cx| { crate::ui::windows::WindowRegistry::init(cx); let ws = WorkspaceId::new(); let epoch = cx .default_global::() .windows .entry(ws) .or_default() .epoch; owe_rehydration(cx, ws, epoch, Adopt::IfEmpty); ws }); // Well past the first backoff: the armed retry really runs, rather than // the test ending while it is still asleep. cx.executor().advance_clock(rehydrate_backoff(1) * 2); cx.executor().run_until_parked(); cx.update(|cx| { assert!( cx.default_global::().windows[&ws] .rehydrate .is_some(), "the debt outlives a retry that found nothing to pay it into" ); }); } #[gpui::test] fn a_window_that_filled_up_while_owed_keeps_what_it_has(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); let stale = [TabId::new(), TabId::new()]; let arm = |cx: &mut App, adopt, over: &[TabId]| { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.rehydrate = Some(adopt); state.owed_over = over.to_vec(); }; arm(cx, Adopt::Replace, &[]); assert!( take_rehydrate(cx, ws, &[]).is_some(), "an empty window is exactly the one that still needs its layout" ); assert!( take_rehydrate(cx, ws, &[]).is_none(), "the debt is claimed once" ); // #579: the tabs a `Replace` was ordered over are the ones it exists // to replace. Reading them as "the user moved on" abandoned the // retry on its first attempt for every resync of a window that kept // its tabs — a daemon back as a new process, a remote server // restarted — and the resync silently did nothing at all. arm(cx, Adopt::Replace, &stale); assert!( take_rehydrate(cx, ws, &stale).is_some(), "the dead tabs the resync was ordered over cannot be the reason to drop it" ); arm(cx, Adopt::Replace, &stale); assert!( take_rehydrate(cx, ws, &stale[..1]).is_some(), "closing some of them is not filling the window in either" ); arm(cx, Adopt::Replace, &stale); assert!( take_rehydrate(cx, ws, &[stale[0], TabId::new()]).is_none(), "replaying an older layout over the user's new tabs is worse than not retrying" ); assert!( cx.default_global::().windows[&ws] .rehydrate .is_none(), "and the dropped retry must not linger" ); arm(cx, Adopt::IfEmpty, &[]); assert!( take_rehydrate(cx, ws, &[TabId::new()]).is_some(), "IfEmpty polices that itself, and still owes the mirror a pull" ); }); } /// #579: the other half of an abandoned `Replace`. Dropping the retry is a /// decision about what to *put up*; it says nothing about the window having /// learned what belongs in the workspace, and it never did learn — the pull /// it was told to make failed. /// /// Left `informed`, that window went on to diff at `SyncScope::Full` /// against a mirror `start_prime` had just refilled from the machine, so /// the one tab the user opened over an emptied window emitted `TabClose` /// for every other tab on it. Those panes' records went with them while /// their shells kept running, and nothing tree-driven could reach them /// again — the same damage as #554, through a door #554 did not close. #[gpui::test] fn a_resync_takes_back_the_licence_to_prune_until_a_pull_lands(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.informed = true; } resync_window_from_tree(cx, ws); assert!( !cx.default_global::().windows[&ws].informed, "a window told its contents are not the workspace cannot still speak for it" ); // The shape the damage needs: the mirror back from the machine, // full of the tabs the window never adopted. Without the licence // the sync is additive, which // `an_additive_diff_never_closes_tabs_the_window_has_not_seen` // pins as closing nothing. { let state = cx .default_global::() .windows .get_mut(&ws) .unwrap(); state.rehydrate = None; state.sync = SyncPhase::Primed(WsMirror { tabs: vec![TreeTab::leaf(1), TreeTab::leaf(2)], active: None, }); } assert!( !workspace_is_disposable(cx, ws), "and it cannot delete the workspace out from under those panes either" ); // A pull that lands is the only thing that hands the licence back. mark_window_informed(cx, ws); assert!(cx.default_global::().windows[&ws].informed); }); } /// An `IfEmpty` hydration says the opposite of a `Replace`: keep whatever /// the window has. A window that opened onto a workspace deleted under it /// is put back by writing its own tabs up, and needs the licence to do it. #[gpui::test] fn an_if_empty_pull_leaves_the_licence_where_it_found_it(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); cx.default_global::() .windows .entry(ws) .or_default() .informed = true; hydrate_window_from_tree(cx, ws); assert!(cx.default_global::().windows[&ws].informed); }); } /// #672's residual: a rebuild that put up some of the tabs the tree asked /// for and dropped the rest (`tabs_from_session` drops a tab none of whose /// panes would start). It earns the licence — it did put a layout up — and /// the tabs it dropped are not tabs the user closed, so they must be held /// out of the diff rather than the licence withheld from the whole window. #[gpui::test] fn a_partial_rebuild_holds_the_tabs_it_could_not_put_up(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); let (put_up, failed) = (TabId::new(), TabId::new()); let settled = |cx: &mut App| { let state = &cx.default_global::().windows[&ws]; (state.informed, state.not_rebuilt.clone()) }; settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up]); assert_eq!( settled(cx), (true, vec![failed]), "the window speaks for the tab it put up, and holds the one it could not" ); settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up, failed]); assert_eq!( settled(cx), (true, vec![]), "a later rebuild that puts them all up holds nothing back" ); cx.default_global::() .windows .get_mut(&ws) .unwrap() .informed = false; settle_rebuild(cx, ws, 2, &[put_up, failed], &[]); assert_eq!( settled(cx), (false, vec![put_up, failed]), "a rebuild that produced nothing still does not get to speak for the workspace" ); settle_rebuild(cx, ws, 0, &[], &[]); assert_eq!( settled(cx), (true, vec![]), "an empty tree rebuilt into an empty window is the one case that is genuinely empty" ); // A session with tabs but no ids — what a disk-loaded session // looks like, since `tree_id` is not serialized — that rebuilt // nothing. "No ids" must not read as "no tabs wanted". cx.default_global::() .windows .get_mut(&ws) .unwrap() .informed = false; settle_rebuild(cx, ws, 2, &[], &[]); assert_eq!( settled(cx), (false, vec![]), "the guard counts the tabs asked for, not the ids found" ); }); } /// The consequence, on the sync that follows: the mirror holds both tabs, /// the window shows one, and the one it could not put up must not come out /// of a `Full` diff as `TabClose` — that op deleted from the machine exactly /// the tabs a restart had failed to bring back, panes and all (#672). #[cfg(unix)] #[gpui::test] fn the_next_sync_leaves_a_tab_the_rebuild_could_not_put_up_on_the_machine( cx: &mut gpui::TestAppContext, ) { let (app, mut vcx, _pane_stream) = crate::ui::app::test_window::harness_with_pane(cx); let (put_up, failed) = (TabId::new(), TabId::new()); app.update_in(&mut vcx, |app, _, cx| { let view = crate::core::session::WindowView::default(); let ws = view.id; WorkspaceStore::install_for_test( cx, crate::core::session::WindowViews { views: vec![view], active: Some(ws), }, ); app.workspace = ws; app.tabs[0].tree_id.set(put_up); { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.sync = SyncPhase::Primed(WsMirror { tabs: vec![ TreeTab { id: put_up, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, }, TreeTab { id: failed, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 2 }, }, ], active: Some(put_up), }); // Keeps whatever the sync queues where the test can read it: // with no link, `pump` would otherwise clear the queue and drop // the mirror on its way to a re-pull. state.inflight = true; } settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up]); assert!( cx.default_global::().windows[&ws].informed, "the shape the damage needs: a window licensed to prune, over a mirror \ holding a tab it is not showing" ); sync_window(app, cx); let state = &cx.default_global::().windows[&ws]; assert!( !state .queue .iter() .any(|op| matches!(op, ControlRequest::TabClose { .. })), "the tab that failed to come back is not one the user closed: {:?}", state.queue ); match &state.sync { SyncPhase::Primed(mirror) => assert_eq!( mirror.tabs.iter().map(|t| t.id).collect::>(), vec![put_up, failed], "and it stays on the machine for the next rebuild to put up" ), SyncPhase::Unprimed { .. } => { panic!("the sync must not have thrown the mirror away") } } }); } #[test] fn a_ratio_delta_is_clamped_to_the_servers_band_not_a_narrower_one() { let mut pane = Pane::split_node(gpui::Axis::Horizontal, 0.5, Pane::Empty, Pane::Empty); assert!(set_gui_ratio(&mut pane, &[], 0.07)); match &pane { Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.07), _ => unreachable!("built as a split"), } assert!(set_gui_ratio(&mut pane, &[], 0.01)); match &pane { Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.05), _ => unreachable!("built as a split"), } } #[test] fn a_tab_created_delta_that_straddled_a_repull_lands_once_in_the_window_mirror() { let mut mirror = WsMirror::default(); let delta = LayoutDelta::TabCreated { at: 0, tab: TreeTab::leaf(1), }; assert!(apply_to_mirror(&mut mirror, &delta)); assert!(apply_to_mirror(&mut mirror, &delta)); assert_eq!(mirror.tabs.len(), 1); } #[gpui::test] fn a_superseded_prime_result_does_not_roll_the_mirror_back(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let ws = WorkspaceId::new(); let stale_epoch = { let state = cx .default_global::() .windows .entry(ws) .or_default(); state.sync = SyncPhase::Unprimed { dirty: false, priming: true, }; state.epoch }; let advanced = WsMirror { tabs: vec![TreeTab::leaf(7)], active: None, }; { let state = cx .default_global::() .windows .get_mut(&ws) .unwrap(); state.epoch += 1; state.sync = SyncPhase::Primed(advanced.clone()); } finish_prime(cx, ws, stale_epoch, Ok((WsMirror::default(), None))); match &cx.default_global::().windows[&ws].sync { SyncPhase::Primed(mirror) => assert_eq!( *mirror, advanced, "the stale pull's empty answer must not replace the advanced mirror" ), _ => panic!("the mirror was dropped entirely"), } }); } fn seed(pane: u64) -> PaneSeed { PaneSeed { pane, cwd: Some(format!("/work/{pane}")), ssh_spec: None, agent: None, shell: None, } } fn leaf(pane: u64) -> DesiredNode { DesiredNode::Leaf { pane, seed: seed(pane), } } fn split(axis: TreeAxis, ratio: f32, a: DesiredNode, b: DesiredNode) -> DesiredNode { DesiredNode::Split { axis, ratio, a: Box::new(a), b: Box::new(b), } } fn tab(id: TabId, root: DesiredNode) -> DesiredTab { DesiredTab { id, name: None, group: None, root, } } #[test] fn seeded_records_carry_the_seed_and_the_windows_own_liveness() { let desired = vec![ tab( TabId::new(), split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)), ), tab(TabId::new(), leaf(3)), ]; let records = seeded_records(&desired, |pane| pane != 2); assert_eq!( records.iter().map(|r| r.id).collect::>(), vec![1, 2, 3] ); assert_eq!(records[0].cwd.as_deref(), Some("/work/1")); assert!(records[0].live, "a pane the window holds open is live"); assert!(!records[1].live, "one still connecting is not yet"); assert!(records[2].live); } fn assert_converged(mirror: &WsMirror, desired: &[DesiredTab]) { assert_eq!(mirror.tabs.len(), desired.len()); for (m, d) in mirror.tabs.iter().zip(desired) { assert_eq!(m.id, d.id); assert_eq!(m.name, d.name); assert_eq!(m.sidebar_group, d.group); assert_eq!(m.root, d.root.to_pane_node()); } } #[test] fn opening_the_first_tab_emits_a_create_carrying_the_client_identity() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let desired = vec![tab(id, leaf(7))]; let ops = diff(ws, &mut mirror, &desired, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::TabCreate { workspace: ws, at: Some(0), pane: seed(7), tab: Some(id), }], "a created tab is active on the server, so no separate active op" ); assert_converged(&mirror, &desired); assert_eq!(mirror.active, Some(id)); } #[test] fn a_split_emits_one_pane_split_against_its_sibling() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let one = vec![tab(id, leaf(1))]; diff(ws, &mut mirror, &one, Some(id), SyncScope::Full, &[]); let two = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))]; let ops = diff(ws, &mut mirror, &two, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneSplit { workspace: ws, pane: 1, axis: TreeAxis::Vertical, ratio: 0.5, new: seed(2), first: false, }] ); assert_converged(&mirror, &two); } #[test] fn a_new_pane_on_the_upper_side_splits_with_first_set() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(id, leaf(1))], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, split(TreeAxis::Horizontal, 0.4, leaf(2), leaf(1)))]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneSplit { workspace: ws, pane: 1, axis: TreeAxis::Horizontal, ratio: 0.4, new: seed(2), first: true, }] ); assert_converged(&mirror, &want); } #[test] fn closing_a_pane_emits_pane_close_and_the_split_collapses() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, leaf(1))]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneClose { workspace: ws, pane: 2 }] ); assert_converged(&mirror, &want); } #[test] fn dragging_a_pane_across_the_layout_emits_one_pane_move() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); // 1 | 2 // ——+—— // 3 let grid = |a: DesiredNode, b: DesiredNode| split(TreeAxis::Vertical, 0.5, a, b); let before = vec![tab( id, grid(split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), leaf(3)), )]; diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]); // 1 dropped below 3, which leaves 2 holding the top row alone. let after = vec![tab( id, grid(leaf(2), split(TreeAxis::Vertical, 0.5, leaf(3), leaf(1))), )]; let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneMove { workspace: ws, pane: 1, to: 3, axis: TreeAxis::Vertical, first: false, }], "the tab is reshaped in place, not closed and rebuilt" ); assert_converged(&mirror, &after); } #[test] fn merging_a_tab_into_another_moves_its_panes_and_takes_the_tab_with_them() { let ws = WorkspaceId::new(); let (host, guest) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let before = vec![ tab(host, leaf(1)), tab(guest, split(TreeAxis::Horizontal, 0.5, leaf(2), leaf(3))), ]; diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]); // The guest tab dropped on the right of pane 1, arriving as the column // of two it already was. let after = vec![tab( host, split( TreeAxis::Horizontal, 0.5, leaf(1), split(TreeAxis::Vertical, 0.5, leaf(2), leaf(3)), ), )]; let ops = diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]); assert_eq!( ops, vec![ ControlRequest::PaneMove { workspace: ws, pane: 2, to: 1, axis: TreeAxis::Horizontal, first: false, }, ControlRequest::PaneMove { workspace: ws, pane: 3, to: 2, axis: TreeAxis::Vertical, first: false, }, ], "the panes cross one at a time and the emptied tab goes with them, \ rather than a tab's worth of panes being closed and respawned" ); assert_converged(&mirror, &after); assert_eq!(mirror.active, Some(host)); } #[test] fn a_tab_grafted_above_a_whole_layout_still_converges() { let ws = WorkspaceId::new(); let (host, guest) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let before = vec![ tab(host, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2))), tab(guest, leaf(3)), ]; diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]); // Dropped against the host's outer edge, so the newcomer sits above the // whole two-pane layout rather than beside one of its panes. No single // `PaneMove` can say that, and `migrate_panes` says nothing at all: the // passes after it have to land the tab anyway, by the rebuild they have // always fallen back to. let after = vec![tab( host, split( TreeAxis::Horizontal, 0.33, leaf(3), split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), ), )]; diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]); assert_converged(&mirror, &after); } #[test] fn a_pane_leaving_for_a_tab_of_its_own_gives_it_up_before_it_asks_for_it() { let ws = WorkspaceId::new(); let (held, fresh) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let before = vec![tab( held, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), )]; diff(ws, &mut mirror, &before, Some(held), SyncScope::Full, &[]); // Pane 2 dropped on the strip ahead of the tab it came from, so the tab // it becomes is desired *first*. let after = vec![tab(fresh, leaf(2)), tab(held, leaf(1))]; let ops = diff(ws, &mut mirror, &after, Some(fresh), SyncScope::Full, &[]); assert_eq!( ops, vec![ ControlRequest::PaneClose { workspace: ws, pane: 2, }, ControlRequest::TabCreate { workspace: ws, at: Some(0), pane: seed(2), tab: Some(fresh), }, ], "the machine refuses a pane that is in two tabs at once, so the old \ tab lets go before the new one is built" ); assert_converged(&mirror, &after); } #[test] fn a_move_that_lands_on_a_new_ratio_settles_it_after_the_move() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let before = vec![tab( id, split( TreeAxis::Vertical, 0.5, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), leaf(3), ), )]; diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]); let after = vec![tab( id, split( TreeAxis::Vertical, 0.5, leaf(2), split(TreeAxis::Vertical, 0.25, leaf(3), leaf(1)), ), )]; let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ ControlRequest::PaneMove { workspace: ws, pane: 1, to: 3, axis: TreeAxis::Vertical, first: false, }, ControlRequest::PaneSetRatio { workspace: ws, tab: id, path: vec![Side::B], ratio: 0.25, }, ], "a move splits at a half, so a wanted ratio needs its own op" ); assert_converged(&mirror, &after); } #[test] fn a_swap_no_single_op_expresses_rebuilds_the_tab_whole() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let before = vec![tab( id, split( TreeAxis::Vertical, 0.5, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(4)), ), )]; diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]); // 1 and 4 trade corners: two panes moved, which no one op describes. let after = vec![tab( id, split( TreeAxis::Vertical, 0.5, split(TreeAxis::Horizontal, 0.5, leaf(4), leaf(2)), split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(1)), ), )]; let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]); assert!( matches!(ops.first(), Some(ControlRequest::TabClose { .. })), "expected the rebuild fallback, got {ops:?}" ); assert_converged(&mirror, &after); } #[test] fn a_revived_leaf_emits_pane_replace_with_the_successors_seed() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(9)))]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneReplace { workspace: ws, old: 2, new: seed(9), }] ); assert_converged(&mirror, &want); } #[test] fn a_ratio_drag_emits_set_ratio_with_the_splits_path() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let nested = |r| { split( TreeAxis::Vertical, 0.5, leaf(1), split(TreeAxis::Horizontal, r, leaf(2), leaf(3)), ) }; diff( ws, &mut mirror, &[tab(id, nested(0.5))], Some(id), SyncScope::Full, &[], ); let want = vec![tab(id, nested(0.7))]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::PaneSetRatio { workspace: ws, tab: id, path: vec![Side::B], ratio: 0.7, }] ); assert_converged(&mirror, &want); } #[test] fn closing_a_tab_emits_tab_close_and_heals_the_active_tab() { let ws = WorkspaceId::new(); let (a, b) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(a, leaf(1)), tab(b, leaf(2))], Some(b), SyncScope::Full, &[], ); let want = vec![tab(a, leaf(1))]; let ops = diff(ws, &mut mirror, &want, None, SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::TabClose { workspace: ws, tab: b }], "the heal is the server's own rule, so no active op crosses" ); assert_converged(&mirror, &want); assert_eq!(mirror.active, Some(a)); } #[test] fn a_tab_reorder_emits_moves_that_land_the_windows_order() { let ws = WorkspaceId::new(); let (a, b, c) = (TabId::new(), TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let before = [tab(a, leaf(1)), tab(b, leaf(2)), tab(c, leaf(3))]; diff(ws, &mut mirror, &before, Some(c), SyncScope::Full, &[]); let want = vec![tab(c, leaf(3)), tab(a, leaf(1)), tab(b, leaf(2))]; let ops = diff(ws, &mut mirror, &want, Some(c), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::TabMove { workspace: ws, tab: c, to: 0 }] ); assert_converged(&mirror, &want); } #[test] fn renaming_and_regrouping_emit_their_label_ops() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(id, leaf(1))], Some(id), SyncScope::Full, &[], ); let mut named = tab(id, leaf(1)); named.name = Some("build".into()); named.group = Some("/repo".into()); let want = vec![named]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ ControlRequest::TabRename { workspace: ws, tab: id, name: Some("build".into()), }, ControlRequest::TabSetGroup { workspace: ws, tab: id, group: Some("/repo".into()), }, ] ); assert_converged(&mirror, &want); } #[test] fn switching_tabs_emits_only_set_active_tab() { let ws = WorkspaceId::new(); let (a, b) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); let both = [tab(a, leaf(1)), tab(b, leaf(2))]; diff(ws, &mut mirror, &both, Some(b), SyncScope::Full, &[]); let ops = diff(ws, &mut mirror, &both, Some(a), SyncScope::Full, &[]); assert_eq!( ops, vec![ControlRequest::WorkspaceSetActiveTab { workspace: ws, tab: a }] ); assert_eq!(mirror.active, Some(a)); } #[test] fn a_deep_tree_materializes_top_split_first_and_converges() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let want = vec![tab( id, split( TreeAxis::Horizontal, 0.6, split(TreeAxis::Vertical, 0.3, leaf(1), leaf(2)), split(TreeAxis::Vertical, 0.7, leaf(3), leaf(4)), ), )]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( ops, vec![ ControlRequest::TabCreate { workspace: ws, at: Some(0), pane: seed(1), tab: Some(id), }, ControlRequest::PaneSplit { workspace: ws, pane: 1, axis: TreeAxis::Horizontal, ratio: 0.6, new: seed(3), first: false, }, ControlRequest::PaneSplit { workspace: ws, pane: 1, axis: TreeAxis::Vertical, ratio: 0.3, new: seed(2), first: false, }, ControlRequest::PaneSplit { workspace: ws, pane: 3, axis: TreeAxis::Vertical, ratio: 0.7, new: seed(4), first: false, }, ] ); assert_converged(&mirror, &want); } #[test] fn an_unchanged_window_emits_nothing() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))]; diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]), Vec::new() ); } #[test] fn a_tab_whose_panes_are_all_still_spawning_is_held_not_closed() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(id, leaf(1))], Some(id), SyncScope::Full, &[], ); let ops = diff(ws, &mut mirror, &[], None, SyncScope::Full, &[id]); assert_eq!(ops, Vec::new()); assert_eq!(mirror.tabs.len(), 1, "the daemon tab survives the wait"); } #[test] fn an_additive_diff_never_closes_tabs_the_window_has_not_seen() { let ws = WorkspaceId::new(); let (a, b) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(a, leaf(1)), tab(b, leaf(2))], Some(b), SyncScope::Full, &[], ); let fresh = TabId::new(); let ops = diff( ws, &mut mirror, &[tab(fresh, leaf(9))], Some(fresh), SyncScope::Additive, &[], ); assert_eq!( ops, vec![ControlRequest::TabCreate { workspace: ws, at: Some(2), pane: seed(9), tab: Some(fresh), }], "appended after the tabs it has not seen; nothing closed or moved" ); assert_eq!(mirror.tabs.len(), 3); } #[test] fn deltas_advance_the_mirror_exactly_as_the_writers_operations_did() { let ws = WorkspaceId::new(); let id = TabId::new(); let mut watcher = WsMirror::default(); let tree_tab = TreeTab { id, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, }; assert!(apply_to_mirror( &mut watcher, &LayoutDelta::TabCreated { at: 0, tab: tree_tab, }, )); assert!(apply_to_mirror( &mut watcher, &LayoutDelta::ActiveTabChanged { tab: id }, )); assert!(apply_to_mirror( &mut watcher, &LayoutDelta::TabRestructured { tab: TreeTab { id, name: None, sidebar_group: None, root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.5, a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, }, pane: None, }, )); assert!(apply_to_mirror( &mut watcher, &LayoutDelta::RatioChanged { tab: id, path: Vec::new(), ratio: 0.7, }, )); let mut writer = WsMirror::default(); diff( ws, &mut writer, &[tab(id, leaf(1))], Some(id), SyncScope::Full, &[], ); let final_state = vec![tab(id, split(TreeAxis::Vertical, 0.7, leaf(1), leaf(2)))]; diff( ws, &mut writer, &final_state, Some(id), SyncScope::Full, &[], ); assert_eq!(watcher, writer); } #[test] fn a_delta_about_a_tab_the_mirror_does_not_hold_reports_itself() { let mut mirror = WsMirror::default(); assert!( !apply_to_mirror( &mut mirror, &LayoutDelta::TabRenamed { tab: TabId::new(), name: Some("x".into()), }, ), "an unappliable delta must say so, so the caller re-pulls" ); assert!(!apply_to_mirror( &mut mirror, &LayoutDelta::TabClosed { tab: TabId::new() }, ),); } #[test] fn a_lowered_leaf_carries_its_pane_id_and_its_agent_whatever_live_says() { use tty7_core::core::cli_agent::CLIAgent; let tab_id = TabId::new(); let ws = tty7_core::core::machine::Workspace { tabs: vec![TreeTab { id: tab_id, name: Some("build".into()), sidebar_group: Some("/repo".into()), root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.3, a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, }], active_tab: Some(tab_id), ..Default::default() }; let panes = vec![ PaneRecord { id: 1, cwd: Some("/work".into()), live: true, ..PaneRecord::new(1) }, PaneRecord { id: 2, cwd: Some("/work/api".into()), live: false, agent: Some(AgentFacts { agent: CLIAgent::Claude, session_id: Some("sid".into()), launch_argv: Some(vec!["claude".into()]), status: None, }), ..PaneRecord::new(2) }, ]; let session = session_from_tree(&ws, &panes); assert_eq!(session.tabs.len(), 1); assert_eq!(session.active, 0); let tab = &session.tabs[0]; assert_eq!( tab.tree_id, Some(tab_id), "the daemon tab's identity rides along" ); assert_eq!(tab.name.as_deref(), Some("build")); let SessionPane::Split { ratio, a, b, .. } = &tab.pane else { panic!("the split survives the lowering"); }; assert!((ratio - 0.3).abs() < 1e-6); match &**a { SessionPane::Leaf { pane_id, cwd, .. } => { assert_eq!(*pane_id, Some(1), "a live pane re-attaches by its id"); assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work"))); } _ => panic!("leaf"), } match &**b { SessionPane::Leaf { pane_id, cwd, agent, agent_session_id, .. } => { assert_eq!( *pane_id, Some(2), "a pane the tree calls dead still goes down by its id: the flag is a \ cached observation from another process — reloaded as false on every \ server start — and attaching is what settles it. Believing the flag \ here spawned fresh shells over running sessions." ); assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api"))); assert_eq!(*agent, Some(CLIAgent::Claude)); assert_eq!(agent_session_id.as_deref(), Some("sid")); } _ => panic!("leaf"), } } /// The regression behind "switching workspaces threw away every session". /// /// Two servers had started against one config dir — one holding the control /// socket with an empty pane registry, the other holding the panes — so /// `MachineGet` answered with every `live` still `false`, the value /// `load_machine` stamps on a cold read. Erasing the id on that made the /// restore spawn a fresh shell over each running one, and nineteen live /// agent sessions went out with it. /// /// The id has to survive a `live: false`, because nothing here is entitled /// to declare a pane dead. Attaching is. #[test] fn a_pane_the_tree_calls_dead_still_goes_down_by_its_id() { let tab_id = TabId::new(); let ws = tty7_core::core::machine::Workspace { tabs: vec![TreeTab { id: tab_id, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 7 }, }], active_tab: Some(tab_id), ..Default::default() }; let panes = vec![PaneRecord { id: 7, cwd: Some("/work".into()), live: false, ..PaneRecord::new(7) }]; match &session_from_tree(&ws, &panes).tabs[0].pane { SessionPane::Leaf { pane_id, .. } => assert_eq!( *pane_id, Some(7), "the attach decides whether pane 7 is still there; this must not pre-empt it" ), _ => panic!("leaf"), } } #[test] fn a_dangling_active_tab_in_the_pulled_tree_falls_back_to_the_first() { let ws = tty7_core::core::machine::Workspace { tabs: vec![TreeTab { id: TabId::new(), name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, }], active_tab: Some(TabId::new()), ..Default::default() }; assert_eq!(session_from_tree(&ws, &[]).active, 0); } #[test] fn a_pane_id_reused_in_another_tab_is_never_read_as_a_replace() { let ws = WorkspaceId::new(); let (a, b) = (TabId::new(), TabId::new()); let mut mirror = WsMirror::default(); diff( ws, &mut mirror, &[tab(a, leaf(1)), tab(b, leaf(2))], Some(b), SyncScope::Full, &[], ); let want = vec![tab(a, leaf(2)), tab(b, leaf(2))]; let ops = diff(ws, &mut mirror, &want, Some(b), SyncScope::Full, &[]); assert!( !ops.iter() .any(|op| matches!(op, ControlRequest::PaneReplace { .. })), "got {ops:?}" ); } }