mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-23 16:02:27 +00:00
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree migration landed, so the coexistence scaffolding comes out: - core::workspace_store is deleted. Attachment and the data-directory resolution (TTY7_DATA_DIR, XDG fallback chain) move into core::machine, which was already their only consumer; Attachment loses its vestigial serde derives (it never crosses disk or wire). - The control dialect drops the four record verbs, the ReplyOk::Json payload they answered with, and the WorkspaceChanged event. Their serde names (and the workspace-store capability bit) are recorded as burned rather than reserved by any mechanism — the dialect has no numbered slots to hold, so a comment at each site is the guard, plus the handshake test asserting the bit never reappears. - host::server loses Services.workspaces, the verb arms, the per-connection store subscription and its WorkspaceChanged forwarder, and the store half of attach/detach/teardown. Attachment data now lives solely in the tree: a workspace the tree does not list records no data half (the registry's live handles still move, so takeover behaviour is unchanged), and it appears the moment the workspace does. Services::with_workspaces/and_machine collapse into with_machine; control_services becomes a single match. - The attach/takeover tests move onto MachineStore wholesale, attaching to workspaces created in a real tree; the record-store round-trip and fan-out tests go (tests/machine_tree.rs has carried the tree equivalents since the verbs landed), and tests/workspace_store.rs is deleted with the serde_json dev-dependency that existed only for it. machine.rs gains the two guarantees the old suite held uniquely: an attachment dies with its workspace structurally, and the default path resolution ends at the documented file. - The GUI's dead WorkspaceChanged arm and every stale doc reference go.
523 lines
17 KiB
Rust
523 lines
17 KiB
Rust
//! The machine-owned workspace tree, end to end against a real `tty7-server`
|
|
//! child process.
|
|
//!
|
|
//! The client is the shipped `ControlClient`, the wire is the control dialect
|
|
//! over real pipes, and the server is the shipped binary owning its tree in a
|
|
//! file. What the process boundary buys here specifically:
|
|
//!
|
|
//! | | Why an in-process store would not do |
|
|
//! |---|---|
|
|
//! | The tree is on **the server's** disk | The whole design is "the daemon owns the structure"; a store in the test's address space proves the data type, not the ownership |
|
|
//! | `machine-tree` is advertised only when served | The capability bit is built from what the *binary* wires up |
|
|
//! | A delta reaches the **other** connection, never the writer | Origin exclusion is the contract that lets a client apply its own edit from the reply and everyone else's from the push |
|
|
//!
|
|
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
|
|
//! another's leftovers and nothing here can touch a developer's real tree.
|
|
|
|
// Unix-only: the server under test is a `--stdio` child, and the two-client
|
|
// case stands up a control socket.
|
|
#![cfg(unix)]
|
|
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tty7_core::core::machine::{Axis, LayoutDelta, MACHINE_FILE, PaneNode, PaneSeed};
|
|
use tty7_core::daemon::control::{
|
|
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, WorkspaceId,
|
|
feature,
|
|
};
|
|
|
|
/// The child, and the only way to end it — a process-backed link is reaped by
|
|
/// its `LinkShutdown`, exactly as in `stdio_conformance.rs`.
|
|
struct ServerProcess {
|
|
child: Mutex<Option<Child>>,
|
|
}
|
|
|
|
impl LinkShutdown for ServerProcess {
|
|
fn shutdown_link(&self) -> io::Result<()> {
|
|
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
|
return Ok(());
|
|
};
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// One connected client: the RPC channel, plus everything the server pushed.
|
|
struct Client {
|
|
control: ControlClient,
|
|
events: Arc<Mutex<Vec<ControlEvent>>>,
|
|
peer_features: Vec<String>,
|
|
}
|
|
|
|
impl Client {
|
|
/// Wait for a `Layout` delta about `workspace` matching `want`, or fail
|
|
/// saying what did arrive. Polled because a push and the reply that caused
|
|
/// it race by construction.
|
|
fn expect_delta(&self, workspace: WorkspaceId, want: impl Fn(&LayoutDelta) -> bool) {
|
|
let key = workspace.to_string();
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
let seen = self
|
|
.events
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.clone();
|
|
if seen.iter().any(|e| {
|
|
matches!(e, ControlEvent::Layout { workspace: w, delta } if *w == key && want(delta))
|
|
}) {
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"no matching Layout delta for {key}; saw {seen:?}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
fn delta_count(&self) -> usize {
|
|
self.events
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.iter()
|
|
.filter(|e| matches!(e, ControlEvent::Layout { .. }))
|
|
.count()
|
|
}
|
|
}
|
|
|
|
/// Start a `tty7-server --stdio --serve` whose tree lives in `data_dir`, and
|
|
/// connect a client to it. `--serve` for the same reason as everywhere else in
|
|
/// these tests: a developer's real daemon must never be bridged into.
|
|
fn connect(data_dir: &Path, token: &str) -> Client {
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
|
.args(["--stdio", "--serve"])
|
|
.env("TTY7_DATA_DIR", data_dir)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("could not start tty7-server --stdio");
|
|
|
|
let stdout = child.stdout.take().expect("piped");
|
|
let stdin = child.stdin.take().expect("piped");
|
|
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
|
child: Mutex::new(Some(child)),
|
|
});
|
|
|
|
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = Arc::clone(&events);
|
|
let control = ControlClient::connect_with(
|
|
stdout,
|
|
stdin,
|
|
Some(closer),
|
|
&ControlHello::host_rpc(token, "test-client"),
|
|
Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)),
|
|
)
|
|
.expect("handshake with tty7-server --stdio");
|
|
|
|
let peer_features = control.hello().features.clone();
|
|
Client {
|
|
control,
|
|
events,
|
|
peer_features,
|
|
}
|
|
}
|
|
|
|
fn data_dir() -> tempfile::TempDir {
|
|
tempfile::TempDir::new().unwrap()
|
|
}
|
|
|
|
fn machine_file(dir: &tempfile::TempDir) -> PathBuf {
|
|
dir.path().join(MACHINE_FILE)
|
|
}
|
|
|
|
fn seed(pane: u64, cwd: &str) -> PaneSeed {
|
|
PaneSeed {
|
|
pane,
|
|
cwd: Some(cwd.to_string()),
|
|
ssh_spec: None,
|
|
agent: None,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The capability bit is the client's cue that the tree verbs are worth a
|
|
/// round trip, and it has to reflect what the shipped binary wired up.
|
|
#[test]
|
|
fn the_server_advertises_the_machine_tree() {
|
|
let dir = data_dir();
|
|
let client = connect(dir.path(), "cap");
|
|
assert!(
|
|
client
|
|
.peer_features
|
|
.iter()
|
|
.any(|f| f == feature::MACHINE_TREE),
|
|
"features were {:?}",
|
|
client.peer_features
|
|
);
|
|
}
|
|
|
|
/// The semantic operations against a real server, and the tree ends up in a
|
|
/// file that server owns. This is "the daemon owns the structure" as a
|
|
/// syscall someone else made, not as a diagram.
|
|
#[test]
|
|
fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
|
|
let dir = data_dir();
|
|
let client = connect(dir.path(), "ops");
|
|
|
|
// Build: a workspace, a tab, a split.
|
|
let ws = match client
|
|
.control
|
|
.call(ControlRequest::WorkspaceCreate {
|
|
name: Some("api".into()),
|
|
workspace: None,
|
|
})
|
|
.expect("create workspace")
|
|
{
|
|
ReplyOk::WorkspaceTree(ws) => *ws,
|
|
other => panic!("expected WorkspaceTree, got {other:?}"),
|
|
};
|
|
let tab = match client
|
|
.control
|
|
.call(ControlRequest::TabCreate {
|
|
workspace: ws.id,
|
|
at: None,
|
|
pane: seed(1, "/home/me/proj"),
|
|
tab: None,
|
|
})
|
|
.expect("create tab")
|
|
{
|
|
ReplyOk::TabTree(tab) => *tab,
|
|
other => panic!("expected TabTree, got {other:?}"),
|
|
};
|
|
client
|
|
.control
|
|
.call(ControlRequest::PaneSplit {
|
|
workspace: ws.id,
|
|
pane: 1,
|
|
axis: Axis::Vertical,
|
|
ratio: 0.3,
|
|
new: seed(2, "/home/me/proj/sub"),
|
|
first: false,
|
|
})
|
|
.expect("split");
|
|
|
|
// Read back through the wire.
|
|
let machine = match client.control.call(ControlRequest::MachineGet).unwrap() {
|
|
ReplyOk::MachineTree(m) => *m,
|
|
other => panic!("expected MachineTree, got {other:?}"),
|
|
};
|
|
assert_eq!(machine.workspaces.len(), 1);
|
|
assert_eq!(machine.workspaces[0].tabs[0].id, tab.id);
|
|
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![1, 2]);
|
|
assert_eq!(machine.panes.len(), 2);
|
|
assert!(
|
|
machine.panes.iter().all(|p| p.live),
|
|
"panes this server was told about in its own lifetime are live"
|
|
);
|
|
|
|
// The file is the server's: the test process never wrote it.
|
|
let text = std::fs::read_to_string(machine_file(&dir)).expect("the server wrote its tree");
|
|
assert!(text.contains(&ws.id.to_string()), "{text}");
|
|
|
|
// A refusal is a client-visible error, not a dropped reply.
|
|
let missing = client
|
|
.control
|
|
.call(ControlRequest::WorkspaceTree {
|
|
workspace: WorkspaceId::new(),
|
|
})
|
|
.unwrap_err();
|
|
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
|
|
}
|
|
|
|
/// **The revival contract, across a real restart.** A second server process
|
|
/// reads the first one's tree; every pane in it is dead (`live == false`), the
|
|
/// leaves still name them, and `PaneReplace` rebinds a leaf to a successor.
|
|
#[test]
|
|
fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors() {
|
|
let dir = data_dir();
|
|
let ws = {
|
|
let first = connect(dir.path(), "first");
|
|
let ws = match first
|
|
.control
|
|
.call(ControlRequest::WorkspaceCreate {
|
|
name: None,
|
|
workspace: None,
|
|
})
|
|
.unwrap()
|
|
{
|
|
ReplyOk::WorkspaceTree(ws) => *ws,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
first
|
|
.control
|
|
.call(ControlRequest::TabCreate {
|
|
workspace: ws.id,
|
|
at: None,
|
|
pane: seed(7, "/home/me/proj"),
|
|
tab: None,
|
|
})
|
|
.unwrap();
|
|
first.control.close();
|
|
ws
|
|
};
|
|
|
|
// A brand-new server process over the same file.
|
|
let second = connect(dir.path(), "second");
|
|
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
|
|
ReplyOk::MachineTree(m) => *m,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
let record = machine
|
|
.panes
|
|
.iter()
|
|
.find(|p| p.id == 7)
|
|
.expect("the pane record survives the restart");
|
|
assert!(!record.live, "a restarted server has no live panes");
|
|
assert_eq!(
|
|
record.cwd.as_deref(),
|
|
Some("/home/me/proj"),
|
|
"the facts a successor spawns from survive"
|
|
);
|
|
assert_eq!(
|
|
machine.workspaces[0].tabs[0].root,
|
|
PaneNode::Leaf { pane: 7 },
|
|
"the leaf still names the dead pane — the revival slot"
|
|
);
|
|
|
|
// Revive: a fresh pane takes the leaf, the spent record goes.
|
|
second
|
|
.control
|
|
.call(ControlRequest::PaneReplace {
|
|
workspace: ws.id,
|
|
old: 7,
|
|
new: seed(1, "/home/me/proj"),
|
|
})
|
|
.expect("replace");
|
|
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
|
|
ReplyOk::MachineTree(m) => *m,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
assert_eq!(
|
|
machine.workspaces[0].tabs[0].root,
|
|
PaneNode::Leaf { pane: 1 }
|
|
);
|
|
assert!(machine.panes.iter().all(|p| p.id != 7));
|
|
}
|
|
|
|
/// Two clients on one server. An operation by one reaches the other as a
|
|
/// `Layout` delta and never comes back to its author — the mechanism that
|
|
/// replaces whole-record last-writer-wins with edits that all land.
|
|
#[test]
|
|
fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
|
use tty7_core::host::local::LocalHost;
|
|
use tty7_core::host::server;
|
|
|
|
let dir = data_dir();
|
|
let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir));
|
|
let sock = dir.path().join("control.sock");
|
|
let listener = server::bind_control_socket(&sock).unwrap();
|
|
{
|
|
let machine = Arc::clone(&machine);
|
|
std::thread::spawn(move || {
|
|
server::serve_listener_with(
|
|
listener,
|
|
LocalHost::new(),
|
|
server::Services::with_machine(machine),
|
|
)
|
|
});
|
|
}
|
|
|
|
let writer = bridged(&sock, "writer");
|
|
let watcher = bridged(&sock, "watcher");
|
|
assert!(
|
|
writer
|
|
.peer_features
|
|
.iter()
|
|
.any(|f| f == feature::MACHINE_TREE)
|
|
);
|
|
// Make sure the watcher's subscription is up (its server thread subscribes
|
|
// before answering its first request).
|
|
watcher.control.call(ControlRequest::Ping).unwrap();
|
|
|
|
let ws = match writer
|
|
.control
|
|
.call(ControlRequest::WorkspaceCreate {
|
|
name: Some("shared".into()),
|
|
workspace: None,
|
|
})
|
|
.unwrap()
|
|
{
|
|
ReplyOk::WorkspaceTree(ws) => *ws,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
let tab = match writer
|
|
.control
|
|
.call(ControlRequest::TabCreate {
|
|
workspace: ws.id,
|
|
at: None,
|
|
pane: seed(3, "/srv"),
|
|
tab: None,
|
|
})
|
|
.unwrap()
|
|
{
|
|
ReplyOk::TabTree(tab) => *tab,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
|
|
watcher.expect_delta(
|
|
ws.id,
|
|
|d| matches!(d, LayoutDelta::WorkspaceCreated { workspace } if workspace.id == ws.id),
|
|
);
|
|
watcher.expect_delta(
|
|
ws.id,
|
|
|d| matches!(d, LayoutDelta::TabCreated { tab: t, .. } if t.id == tab.id),
|
|
);
|
|
// The created tab became active, and the *change of active tab* is its own
|
|
// delta — implicit activation must not be something a client re-derives.
|
|
watcher.expect_delta(
|
|
ws.id,
|
|
|d| matches!(d, LayoutDelta::ActiveTabChanged { tab: t } if *t == tab.id),
|
|
);
|
|
assert_eq!(
|
|
writer.delta_count(),
|
|
0,
|
|
"a client must not be pushed its own operation"
|
|
);
|
|
|
|
// …and the rule holds in the other direction.
|
|
watcher
|
|
.control
|
|
.call(ControlRequest::TabRename {
|
|
workspace: ws.id,
|
|
tab: tab.id,
|
|
name: Some("build".into()),
|
|
})
|
|
.unwrap();
|
|
writer.expect_delta(
|
|
ws.id,
|
|
|d| matches!(d, LayoutDelta::TabRenamed { name: Some(n), .. } if n == "build"),
|
|
);
|
|
assert_eq!(watcher.delta_count(), 3, "still only the writer's own ops");
|
|
}
|
|
|
|
/// Takeover semantics on the new tree, with **no record store served at
|
|
/// all**: the attach verbs predate the tree, and their contract — newcomer
|
|
/// wins, the displaced session is told, a stale detach cannot evict the
|
|
/// usurper — must survive the record store's retirement.
|
|
#[test]
|
|
fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
|
use tty7_core::host::local::LocalHost;
|
|
use tty7_core::host::server;
|
|
|
|
let dir = data_dir();
|
|
let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir));
|
|
let sock = dir.path().join("control.sock");
|
|
let listener = server::bind_control_socket(&sock).unwrap();
|
|
{
|
|
let machine = Arc::clone(&machine);
|
|
std::thread::spawn(move || {
|
|
server::serve_listener_with(
|
|
listener,
|
|
LocalHost::new(),
|
|
server::Services::with_machine(machine),
|
|
)
|
|
});
|
|
}
|
|
let ws = machine
|
|
.workspace_create(None, Some("shared".into()), None)
|
|
.unwrap();
|
|
|
|
let laptop = bridged(&sock, "laptop");
|
|
let desktop = bridged(&sock, "desktop");
|
|
|
|
let attach = |client: &Client| {
|
|
client.control.call(ControlRequest::WorkspaceAttach {
|
|
id: ws.id.to_string(),
|
|
})
|
|
};
|
|
match attach(&laptop).expect("first attach") {
|
|
ReplyOk::Attached { took_over_from } => assert_eq!(took_over_from, None),
|
|
other => panic!("{other:?}"),
|
|
}
|
|
assert_eq!(
|
|
machine.attachment(ws.id).map(|a| a.hostname),
|
|
Some("laptop".into()),
|
|
"the tree's own record says who holds the workspace"
|
|
);
|
|
|
|
// The newcomer wins, learns whom it displaced, and the displaced session
|
|
// is pushed a Preempted notice.
|
|
match attach(&desktop).expect("takeover") {
|
|
ReplyOk::Attached { took_over_from } => {
|
|
assert_eq!(took_over_from.as_deref(), Some("laptop"));
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
let seen = laptop.events.lock().unwrap().clone();
|
|
if seen.iter().any(|e| {
|
|
matches!(e, ControlEvent::Preempted { workspace, by }
|
|
if *workspace == ws.id.to_string() && by == "desktop")
|
|
}) {
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "no Preempted push; saw {seen:?}");
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
|
|
// The preempted session tidying up must not evict the usurper.
|
|
laptop
|
|
.control
|
|
.call(ControlRequest::WorkspaceDetach {
|
|
id: ws.id.to_string(),
|
|
})
|
|
.expect("a stale detach is success, not eviction");
|
|
assert_eq!(
|
|
machine.attachment(ws.id).map(|a| a.hostname),
|
|
Some("desktop".into())
|
|
);
|
|
}
|
|
|
|
/// A `--stdio --bridge` child connected to an already-listening control
|
|
/// socket — the two-hop shape a real multi-client machine has.
|
|
fn bridged(sock: &Path, token: &str) -> Client {
|
|
let hello = ControlHello::host_rpc(token, token);
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
|
.args(["--stdio", "--bridge", "--control-sock"])
|
|
.arg(sock)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("could not start the bridging client");
|
|
let stdout = child.stdout.take().expect("piped");
|
|
let stdin = child.stdin.take().expect("piped");
|
|
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
|
child: Mutex::new(Some(child)),
|
|
});
|
|
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = Arc::clone(&events);
|
|
let control = ControlClient::connect_with(
|
|
stdout,
|
|
stdin,
|
|
Some(closer),
|
|
&hello,
|
|
Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)),
|
|
)
|
|
.expect("bridge handshake");
|
|
let peer_features = control.hello().features.clone();
|
|
Client {
|
|
control,
|
|
events,
|
|
peer_features,
|
|
}
|
|
}
|