Files
tty7/src/ui/tree_sync.rs
T
l0ng-ai 44ce34a978 refactor(tree-sync): one definition of the panes a window answers for
`settle_census` and `sweep_parked` each built `parked ∪ spawned` from the
state, separately. I introduced that duplication myself when I added the
first of them earlier on this branch, by copying the shape of the second.

They are not interchangeable readings. One decides whether the tree is
worth pulling, the other which panes to end — so drift means either
ending a pane the census never weighed, or pulling for one the sweep will
not judge. `census_of` is now the single answer to "what is this window
still holding".

Which also made the gap visible. Mutating the census showed the `spawned`
half held by nothing: `the_census_holds_the_panes_this_window_made` read
`s.spawned` straight off the state, so it proved the set was filled in
and never that the decision reads it. Dropping `spawned` from the census
left the test green while a pane spawned and not yet in the tree went
back to being invisible to the sweep — which is the whole of the stray
shell leak that set exists to close.

It asserts through `census_of` now, on both halves, and each fails when
dropped. `stranded_of`'s two conditions were already held.
2026-08-23 21:27:23 +08:00

5531 lines
210 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Arc<ControlClient>> {
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<ControlClient>),
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<Arc<ControlClient>>) -> 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<String>,
pub group: Option<String>,
pub root: DesiredNode,
}
#[derive(Debug, Clone)]
pub(crate) enum DesiredNode {
Leaf {
pane: u64,
seed: PaneSeed,
},
Split {
axis: TreeAxis,
ratio: f32,
a: Box<DesiredNode>,
b: Box<DesiredNode>,
},
}
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<DesiredTab>, Option<TabId>, Vec<TabId>) {
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<DesiredNode> {
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<PaneRecord> {
fn walk(node: &DesiredNode, live: &impl Fn(u64) -> bool, out: &mut Vec<PaneRecord>) {
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<TreeTab>,
pub active: Option<TabId>,
}
#[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<TabId>,
scope: SyncScope,
held: &[TabId],
) -> Vec<ControlRequest> {
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<ControlRequest>,
) {
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<u64>,
) -> 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<ControlRequest>,
) {
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<ControlRequest>,
) {
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<ControlRequest>,
) {
{
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<u64> = wanted
.iter()
.copied()
.filter(|p| !have.contains(p))
.collect();
let removed: Vec<u64> = 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<ControlRequest>,
) -> 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<ControlRequest>,
) -> 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<ControlRequest>,
) {
fn walk(
workspace: WorkspaceId,
tab: TabId,
mirror: &mut PaneNode,
desired: &PaneNode,
path: &mut Vec<Side>,
ops: &mut Vec<ControlRequest>,
) {
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<ControlRequest>,
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<Adopt>,
/// 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<TabId>,
/// 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<TabId>,
/// 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<std::path::PathBuf>,
/// 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<String>,
/// 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<u64>,
/// 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<u64>,
/// 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<WorkspaceId, WsState>,
}
impl Global for TreeSync {}
pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) {
let client_ws = app.workspace;
if !cx.has_global::<crate::core::session::WorkspaceStore>() {
return;
}
if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) {
return;
}
let showing: Vec<TabId> = 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::<TreeSync>()
.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<u64> = 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<Adopt> {
let state = cx
.default_global::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.windows
.entry(client_ws)
.or_default()
.informed = true;
}
fn adopt_tab_ids(app: &Tty7App, cx: &App) {
let Some(TreeSync { windows }) = cx.try_global::<TreeSync>() else {
return;
};
let Some(WsState {
sync: SyncPhase::Primed(mirror),
..
}) = windows.get(&app.workspace)
else {
return;
};
let known: Vec<TabId> = 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<u64> = 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::<TreeSync>().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::<TreeSync>() {
let _ = state;
cx.default_global::<TreeSync>().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::<crate::core::session::WorkspaceStore>() {
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::<TreeSync>()
.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<String> {
cx.default_global::<TreeSync>()
.windows
.get(&client_ws)
.and_then(|state| state.chosen_name.clone())
}
pub(crate) fn rename_workspace(cx: &mut App, client_ws: WorkspaceId, name: Option<String>) {
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::<TreeSync>().windows.get_mut(&client_ws)
&& let SyncPhase::Unprimed { priming, .. } = &mut state.sync
{
*priming = false;
}
return;
}
};
let epoch = cx
.default_global::<TreeSync>()
.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::<TreeSync>()
.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<String> = 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::<WorkspaceStore>() {
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<String>,
holds_tabs: bool,
) -> Option<String> {
let chosen = cx
.default_global::<TreeSync>()
.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<String>)> {
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<String>) {
(
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<String>)>,
) {
let Some(state) = cx.default_global::<TreeSync>().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<std::collections::HashSet<u64>> {
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<u64> {
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<u64>) {
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::<TreeSync>().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<WorkspaceId>, pane: u64) {
let Some(owner) = owner else {
return;
};
cx.default_global::<TreeSync>()
.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<u64>,
after: &std::collections::HashSet<u64>,
) {
if before.is_subset(after) {
return;
}
let dropped: Vec<u64> = before.difference(after).copied().collect();
if dropped.is_empty() {
return;
}
if let Some(state) = cx.default_global::<TreeSync>().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<u64> {
cx.default_global::<TreeSync>()
.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<u64> = 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<u64>) {
let parked: Vec<u64> = 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::<TreeSync>().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::<TreeSync>().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<u64>,
tree: &std::collections::HashSet<u64>,
) -> Vec<u64> {
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::<TreeSync>()
.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<ControlRequest> = 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<u64> = 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::<TreeSync>().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 3360% without it and 4350% 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 (02
// 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<u64> {
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::<TreeSync>().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<SessionTab> = 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<TabId>) {
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 <PATH>` 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::<TreeSync>()
.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::<TreeSync>()
.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<TabId> {
if !cx.has_global::<crate::ui::windows::WindowRegistry>() {
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<TabId>) {
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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>().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::<TreeSync>()
.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<String>,
) -> 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<Machine> {
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::<TreeSync>()
.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::<TreeSync>()
.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<u64> = 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::<TreeSync>().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<TabId> = 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<TabId> = 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::<TreeSync>()
.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::<WorkspaceId>().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::<TreeSync>()
.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<Self>,
) -> 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<Self>,
) {
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<Self>,
) -> 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<Self>,
) -> bool {
let remote = WorkspaceStore::all(cx)
.get(self.workspace)
.is_some_and(|w| w.is_remote());
let mut existing: HashMap<u64, PaneSlot> = HashMap::new();
let mut ssh_slots: Vec<PaneSlot> = 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<u64, PaneSlot>,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> Option<Pane> {
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::<std::collections::HashSet<u64>>()
};
// 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::<u64>::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::<u64>::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::<TreeSync>()
.windows
.get(&ws)
.map(|s| s.spawned.clone())
.unwrap_or_default();
assert_eq!(
census,
[7, 9]
.into_iter()
.collect::<std::collections::HashSet<u64>>(),
"every pane the window made, counted once"
);
let other = WorkspaceId::new();
assert!(
!cx.default_global::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>()
.windows
.entry(ws)
.or_default();
name_new_workspace(cx, ws, "deploy".into());
assert_eq!(
cx.default_global::<TreeSync>().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::<TreeSync>().windows[&ws].epoch;
finish_prime(
cx,
ws,
epoch,
Ok((WsMirror::default(), Some("deploy".into()))),
);
assert!(
cx.default_global::<TreeSync>().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::<TreeSync>().windows[&ws].epoch;
finish_prime(
cx,
ws,
epoch,
Ok((WsMirror::default(), Some("keen-marten".into()))),
);
assert!(
cx.default_global::<TreeSync>().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::<TreeSync>().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::<TreeSync>().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::<TreeSync>().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::<TreeSync>().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::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>().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::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.informed = informed;
state.sync = sync;
};
let unprimed = || SyncPhase::Unprimed {
dirty: false,
priming: false,
};
let primed_with =
|tabs: Vec<TreeTab>| 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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap();
state.rehydrate = None;
state.epoch += 1;
}
owe_rehydration(cx, ws, epoch, Adopt::Replace);
assert!(
cx.default_global::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.windows
.entry(ws)
.or_default()
.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
let epoch = cx.default_global::<TreeSync>().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::<TreeSync>()
.windows
.entry(ws)
.or_default()
.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
};
let attempts =
|cx: &mut App| cx.default_global::<TreeSync>().windows[&ws].rehydrate_attempts;
unprimed(cx);
let epoch = cx.default_global::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.informed = true;
}
resync_window_from_tree(cx, ws);
assert!(
!cx.default_global::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>()
.windows
.entry(ws)
.or_default()
.informed = true;
hydrate_window_from_tree(cx, ws);
assert!(cx.default_global::<TreeSync>().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::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>().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::<TreeSync>().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<_>>(),
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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>().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<_>>(),
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:?}"
);
}
}