refactor(server): retire the opaque workspace record store

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.
This commit is contained in:
l0ng-ai
2026-07-30 00:35:49 +08:00
parent 86c711b2ba
commit 382a46ac29
15 changed files with 320 additions and 2400 deletions
Generated
-1
View File
@@ -9606,7 +9606,6 @@ dependencies = [
name = "tty7-server"
version = "26.7.6"
dependencies = [
"serde_json",
"tempfile",
"tty7-core",
]
+116 -14
View File
@@ -2,9 +2,10 @@
//!
//! # What this replaces, and why
//!
//! [`crate::core::workspace_store`] is the previous design: an *opaque* record
//! store, where the client owned the schema and the server filed JSON blobs it
//! never read. That shape was right when there was exactly one writer (the GUI)
//! The previous design (`core::workspace_store`, since deleted) was an
//! *opaque* record store, where the client owned the schema and the server
//! filed JSON blobs it never read. That shape was right when there was
//! exactly one writer (the GUI)
//! and the server's only job was to make a laptop's layout visible from a
//! desktop. It stops being right the moment two clients — a GUI and a CLI, or
//! two GUIs — write concurrently: whole-record `Put` is last-writer-wins, and
@@ -69,18 +70,23 @@ use serde::{Deserialize, Serialize};
use crate::core::cli_agent::CLIAgent;
use crate::core::session::WorkspaceId;
use crate::core::workspace_store::Attachment;
use crate::daemon::protocol::NativeSshSpec;
/// The file's name under the data directory ([`crate::core::workspace_store::DATA_DIR_ENV`]
/// resolves where that is).
/// The file's name under the data directory ([`DATA_DIR_ENV`] resolves where
/// that is).
///
/// Deliberately **not** `workspaces.json`: that file's document is the retired
/// opaque-record store, whose reader quarantines anything it cannot parse. A
/// build downgraded across this refactor must find its old file untouched, and
/// this build's tree must not be "repaired" away by the old reader.
/// Deliberately **not** `workspaces.json`: that name belonged to the retired
/// opaque-record store, whose reader quarantined anything it could not parse.
/// A build downgraded across that refactor must find its old file untouched,
/// and this build's tree must not be "repaired" away by the old reader.
pub const MACHINE_FILE: &str = "machine.json";
/// Overrides where the machine's data directory lives. Set by tests and by a
/// second server on a shared box — the same escape hatch
/// [`CONTROL_SOCK_ENV`](crate::host::server::CONTROL_SOCK_ENV) is for the
/// socket.
pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR";
/// Ceiling on workspaces, carried over from the old store: a client looping on
/// "create workspace" should hit a named error rather than grow the file until
/// the disk fills.
@@ -156,6 +162,41 @@ pub struct Machine {
pub panes: Vec<PaneRecord>,
}
/// Who is currently attached to a workspace.
///
/// **Data only.** The takeover behaviour — push `Preempted { by }` to the old
/// session, close its streams, offer a take-back button — lives in the control
/// server. What is here is the record that machinery needs to exist before it
/// can be written: the random token that tells two connections from the same
/// client apart, and the hostname that fills in "already open on <host>". Both
/// arrive in the [`ControlHello`](crate::daemon::control::ControlHello).
///
/// **Never persisted** (the field carrying it is `#[serde(skip)]`): an
/// attachment describes a live connection; after a server restart there are
/// none, and a stale one on disk would report a takeover against a client
/// that no longer exists.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Attachment {
/// The client's per-session random token, from `ControlHello::client_token`.
pub token: String,
/// The client machine's hostname, shown to the user in the preempted
/// window's status bar.
pub hostname: String,
/// Unix seconds when the attach happened.
pub since: u64,
}
impl Attachment {
/// An attachment stamped now.
pub fn new(token: impl Into<String>, hostname: impl Into<String>) -> Attachment {
Attachment {
token: token.into(),
hostname: hostname.into(),
since: unix_now(),
}
}
}
/// One workspace: a named group of tabs. The unit a window shows and a client
/// attaches to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -1471,11 +1512,44 @@ fn quarantine(path: &Path) {
}
}
/// `<data-dir>/machine.json` — beside the old store's `workspaces.json`, under
/// the same directory resolution ([`crate::core::workspace_store`] documents
/// the order).
/// `<data-dir>/machine.json`.
///
/// | Order | Directory | Why |
/// |---|---|---|
/// | 1 | `$TTY7_DATA_DIR` | Explicit wins; how tests and a second server get their own file |
/// | 2 | `$XDG_DATA_HOME/tty7` | The location the design names, spelled the way XDG spells it |
/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal fallback path |
///
/// Deliberately **not** under the config dir. `views.json` there is the
/// *client's* view state, and a box that is both someone's laptop and someone
/// else's remote must keep the two files apart or one role would overwrite the
/// other's idea of which workspaces exist.
pub fn default_machine_path() -> io::Result<PathBuf> {
crate::core::workspace_store::default_store_path().map(|p| p.with_file_name(MACHINE_FILE))
Ok(data_dir()?.join(MACHINE_FILE))
}
fn data_dir() -> io::Result<PathBuf> {
if let Some(explicit) = std::env::var_os(DATA_DIR_ENV).filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(explicit));
}
#[cfg(not(windows))]
let base = env_dir("XDG_DATA_HOME")
.or_else(|| env_dir("HOME").map(|h| h.join(".local").join("share")));
#[cfg(windows)]
let base = env_dir("LOCALAPPDATA")
.or_else(|| env_dir("USERPROFILE").map(|h| h.join(".local").join("share")));
base.map(|b| b.join("tty7")).ok_or_else(|| {
io::Error::other(format!(
"no home directory to place {MACHINE_FILE} in; set {DATA_DIR_ENV}"
))
})
}
fn env_dir(key: &str) -> Option<PathBuf> {
std::env::var_os(key)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
fn unix_now() -> u64 {
@@ -2115,6 +2189,34 @@ mod tests {
);
}
/// An attachment is a field of its workspace, so deleting the workspace
/// takes it along — there is no table it could go stale in. The retired
/// record store kept a separate attachment list and had to clear it by
/// hand; this pins the structural guarantee that replaced that code.
#[test]
fn an_attachment_dies_with_its_workspace() {
let (store, _dir, ws, _tab) = store_with_tab();
store.attach(ws, Attachment::new("tok", "laptop"));
assert!(store.attachment(ws).is_some());
store.workspace_delete(ws, None).unwrap();
assert_eq!(store.attachment(ws), None);
}
/// The default path ends at the documented file under the data directory —
/// the resolution the retired record store defined and the tree inherited.
#[test]
fn the_default_path_ends_at_the_documented_file() {
match default_machine_path() {
Ok(path) => assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some(MACHINE_FILE)
),
// No home at all (a bare CI container): the error names the
// escape hatch rather than being a mystery.
Err(e) => assert!(e.to_string().contains(DATA_DIR_ENV)),
}
}
// ── Corruption ─────────────────────────────────────────────────────────
#[test]
-1
View File
@@ -31,5 +31,4 @@ pub mod shells;
pub mod ssh_profile;
pub mod threads;
pub mod window_state;
pub mod workspace_store;
pub mod worktree;
File diff suppressed because it is too large Load Diff
+26 -56
View File
@@ -90,7 +90,7 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
/// comparing dialect numbers, so a capability that doesn't move the number is a
/// capability the far machine never gets. A [`feature`] string is the right
/// answer only for something two current servers can genuinely disagree about
/// (the workspace store, which depends on how the server was started); "this
/// (the machine tree, which depends on how the server was started); "this
/// build knows the request and older ones don't" is what the number is for.
///
/// ## History
@@ -184,18 +184,19 @@ pub mod feature {
pub const CONTROL: &str = "control";
/// Serves [`super::ControlRequest`]'s filesystem and git methods — i.e. can
/// back a remote `Host`. Distinct from [`CONTROL`] because a peer could
/// speak the dialect while exposing only the workspace store.
/// speak the dialect while exposing only the workspace tree.
pub const HOST_RPC: &str = "host-rpc";
/// Serves the `Workspace*` requests.
pub const WORKSPACE_STORE: &str = "workspace-store";
// `"workspace-store"` is a burned name: it advertised the retired
// opaque-record scheme (verbs `workspace_list` / `workspace_get` /
// `workspace_put` / `workspace_delete`, event `workspace_changed`), all of
// which are burned with it. Never re-advertise or re-mint any of them with
// a different meaning.
/// Serves the machine-owned workspace tree: the `MachineGet` /
/// `WorkspaceTree` pulls, the semantic tree operations, and the
/// [`super::ControlEvent::Layout`] pushes. Advertised only when the server
/// actually carries a [`crate::core::machine::MachineStore`], so a client
/// learns from the handshake whether the tree verbs are worth a round
/// trip. Distinct from [`WORKSPACE_STORE`], which is the retired
/// opaque-record scheme this one replaces — the two coexist while clients
/// migrate.
/// trip.
pub const MACHINE_TREE: &str = "machine-tree";
/// Can be launched as `--stdio` and bridge its own stdin/stdout to the
/// machine-local socket (the fallback when `AllowStreamLocalForwarding` is
@@ -345,18 +346,10 @@ pub enum ControlRequest {
id: u64,
},
// ----- workspace store (M5; the slots exist, the server doesn't yet) -----
WorkspaceList,
WorkspaceGet {
id: String,
},
WorkspacePut {
id: String,
json: serde_json::Value,
},
WorkspaceDelete {
id: String,
},
// The opaque record store's verbs — `workspace_list` / `workspace_get` /
// `workspace_put` / `workspace_delete` — lived here until the machine tree
// below replaced them. Their serde names are burned (see `feature`); do
// not re-mint them with a different meaning.
// ----- attachment (M6's takeover) ---------------------------------------
/// Claim a workspace for this connection's session, taking it over from
@@ -382,10 +375,10 @@ pub enum ControlRequest {
},
// ----- machine tree (the daemon-owned structure) ------------------------
// The semantic replacement for the opaque `Workspace*` record verbs above:
// instead of a whole-record `Put` (last-writer-wins the moment two clients
// write), each operation names its edit, the server validates it against
// the tree it owns, and everyone else hears an incremental
// The semantic replacement for the retired opaque record verbs: instead of
// a whole-record `Put` (last-writer-wins the moment two clients write),
// each operation names its edit, the server validates it against the tree
// it owns, and everyone else hears an incremental
// [`ControlEvent::Layout`]. Positions cross as `u64` for the same reason
// `Search`'s limits do: a 32-bit server clamps rather than wraps.
/// The whole tree — every workspace, tab and pane record on the machine.
@@ -412,7 +405,8 @@ pub enum ControlRequest {
name: Option<String>,
},
/// Forget a tree workspace and everything under it. Named `Remove` because
/// `WorkspaceDelete` above is taken by the retired record store's verb.
/// `WorkspaceDelete` was the retired record store's verb, and its serde
/// name stays burned.
WorkspaceRemove {
workspace: WorkspaceId,
},
@@ -534,16 +528,12 @@ impl ControlRequest {
// spawns `wsl.exe -l -q`, which is slow enough to deserve the same
// budget as git.
Shells => Duration::from_secs(20),
WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => {
Duration::from_secs(10)
}
// An attach is bookkeeping plus at most one push to a peer that may
// be wedged — the push is `try`-shaped on the server, so this only
// has to cover a slow link, not a slow client.
WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10),
// Tree operations are a locked mutation plus one small file write,
// so the budget is the record verbs': it covers a slow disk, not
// slow work.
// so the budget covers a slow disk, not slow work.
MachineGet
| WorkspaceTree { .. }
| WorkspaceCreate { .. }
@@ -621,8 +611,6 @@ pub enum ReplyOk {
WatchId(u64),
/// [`ControlRequest::Shells`]: what that machine can launch.
Shells(ShellInventory),
/// The workspace store's payload (M5).
Json(serde_json::Value),
/// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the
/// machine whose session was displaced, so the client that *did* the taking
/// can say so — only the notice going the other way is specified,
@@ -782,9 +770,8 @@ pub enum ControlEvent {
workspace: String,
by: String,
},
WorkspaceChanged {
id: String,
},
// `workspace_changed` was the retired record store's change notice; its
// serde name is burned along with the record verbs.
/// One incremental change to one tree workspace on this machine — the
/// push half of the machine-tree verbs. The writer never receives its own
/// operation back (origin exclusion, so an optimistically-applied edit is
@@ -793,7 +780,7 @@ pub enum ControlEvent {
/// [`ControlRequest::WorkspaceTree`].
///
/// `workspace` is the [`WorkspaceId`] rendered as a string, matching how
/// `Preempted` and `WorkspaceChanged` name theirs.
/// `Preempted` names its.
Layout {
workspace: String,
delta: LayoutDelta,
@@ -805,7 +792,7 @@ pub enum ControlEvent {
/// [`RemoteHost`](crate::host::remote::RemoteHost) routes `Watch` and
/// `WatchOverflow` into the subscription that asked for them, because those
/// belong to a caller that is still holding a `WatchSub`. The rest —
/// `Preempted`, `PaneExited`, `AgentStatus`, `WorkspaceChanged` — are about a
/// `Preempted`, `PaneExited`, `AgentStatus`, `Layout` — are about a
/// *window*, and the host layer has no window.
///
/// A process-wide observer rather than a parameter on `connect_with` because
@@ -2001,13 +1988,6 @@ mod tests {
dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()],
},
ControlRequest::WatchClose { id: 7 },
ControlRequest::WorkspaceList,
ControlRequest::WorkspaceGet { id: "w1".into() },
ControlRequest::WorkspacePut {
id: "w1".into(),
json: serde_json::json!({ "tabs": [1, 2, 3] }),
},
ControlRequest::WorkspaceDelete { id: "w1".into() },
]
}
@@ -2054,7 +2034,6 @@ mod tests {
stderr: vec![0x00, 0xff, 0xfe, b'\n'],
})),
ControlReply::Ok(ReplyOk::WatchId(42)),
ControlReply::Ok(ReplyOk::Json(serde_json::json!({ "a": [1, null] }))),
ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no such file")),
ControlReply::Err(WireError::new(
WireErrorKind::PermissionDenied,
@@ -2102,7 +2081,6 @@ mod tests {
workspace: "w1".into(),
by: "other-laptop".into(),
},
ControlEvent::WorkspaceChanged { id: "w1".into() },
]
}
@@ -2712,16 +2690,6 @@ mod tests {
},
s(20),
),
(R::WorkspaceList, s(10)),
(R::WorkspaceGet { id: "w".into() }, s(10)),
(
R::WorkspacePut {
id: "w".into(),
json: serde_json::Value::Null,
},
s(10),
),
(R::WorkspaceDelete { id: "w".into() }, s(10)),
];
assert_eq!(
cases.len(),
@@ -2760,7 +2728,9 @@ mod tests {
assert_eq!(ok.home, "/home/me");
assert!(ok.has_feature(feature::CONTROL));
assert!(ok.has_feature(feature::HOST_RPC));
assert!(!ok.has_feature(feature::WORKSPACE_STORE));
// The retired record store's bit is a burned name and must
// never come back.
assert!(!ok.has_feature("workspace-store"));
}
other => panic!("expected HelloOk, got {other:?}"),
}
+1 -1
View File
@@ -142,7 +142,7 @@ const REPLY_TIMEOUT: Duration = Duration::from_secs(240);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteChannel {
/// Host RPC, the workspace store, event pushes — `daemon::control`.
/// Host RPC, the machine tree, event pushes — `daemon::control`.
#[default]
Control,
/// One pane: `Spawn`/`Attach`/`Input`/`Output` — `daemon::protocol`.
+7 -22
View File
@@ -226,37 +226,22 @@ pub fn run_daemon() -> anyhow::Result<()> {
/// What this machine offers over a control connection, beyond its filesystem.
///
/// The workspace store is why a daemon serves control at all: the workspace
/// The machine tree is why a daemon serves control at all: the workspace
/// list, the tab/pane tree and each pane's facts live on **the machine the
/// panes run on**, so that every client of this machine — the GUI on it, a
/// laptop across the world — sees the same thing. Clients keep only their own
/// view state.
///
/// A machine with no home directory to place the file in still serves files
/// and panes — it simply omits `workspace-store` from its capabilities, and
/// clients see the same "does not serve the workspace store" answer a server
/// and panes — it simply omits `machine-tree` from its capabilities, and
/// clients see the same "does not serve the machine tree" answer a server
/// without one has always given.
pub fn control_services() -> crate::host::server::Services {
use crate::core::machine::MachineStore;
use crate::core::workspace_store::WorkspaceStore;
// Reported on stderr as well as the log, like the socket line in
// [`run_daemon`]: on a headless box the log file is off by default, and
// "which stores does this daemon actually serve" is the first question a
// "does this daemon actually serve the tree" is the first question a
// capability mismatch raises.
let services = match WorkspaceStore::shared() {
Ok(store) => {
eprintln!("workspace store at {}", store.path().display());
crate::host::server::Services::with_workspaces(store)
}
Err(e) => {
eprintln!("no workspace store ({e}); serving files and panes only");
crate::host::server::Services::none()
}
};
// The machine tree rides alongside the record store while clients migrate
// from whole-record `Put` to the semantic operations; both resolve their
// file under the same data directory, so a machine that can hold one can
// hold the other.
match MachineStore::shared() {
Ok(machine) => {
eprintln!("machine tree at {}", machine.path().display());
@@ -265,11 +250,11 @@ pub fn control_services() -> crate::host::server::Services {
// what a client revives from is what the machine saw, not what
// some client last remembered to write.
crate::core::machine::publish_observations(&machine);
services.and_machine(machine)
crate::host::server::Services::with_machine(machine)
}
Err(e) => {
eprintln!("no machine tree ({e}); its verbs stay unserved");
services
eprintln!("no machine tree ({e}); serving files and panes only");
crate::host::server::Services::none()
}
}
}
+1 -1
View File
@@ -162,7 +162,7 @@ impl RemoteHost {
}
/// The underlying connection, for callers that need to speak control
/// directly (the workspace store, once it exists).
/// directly (the machine-tree verbs).
pub fn client(&self) -> &Arc<ControlClient> {
&self.client
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -26,10 +26,5 @@ tty7-core = { path = "../tty7-core" }
# Sandboxes for the suite: an empty directory per case, removed on drop. The
# server is on this machine, so a local temp dir is a path in its namespace.
tempfile = "3"
# Workspace records cross the control wire as opaque JSON, so
# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary
# itself still depends on nothing but `tty7-core`.
serde_json.workspace = true
[lints]
workspace = true
+1 -1
View File
@@ -218,7 +218,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
// the same rule `bridge_panes` follows one dialect over,
// and for the same reason. Two `--stdio` sessions both
// falling through to serving in-process would each hold
// their own `WorkspaceStore` over the one file, and
// their own `MachineStore` over the one file, and
// `persist` writes the whole document: the second to save
// silently drops the first's changes. Their attachment
// registries would be separate too, which makes design
+5 -6
View File
@@ -1,10 +1,9 @@
//! The machine-owned workspace tree, end to end against a real `tty7-server`
//! child process.
//!
//! Same shape and the same reasoning as `workspace_store.rs`, one architecture
//! over: 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:
//! 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 |
//! |---|---|
@@ -330,7 +329,7 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::none().and_machine(machine),
server::Services::with_machine(machine),
)
});
}
@@ -427,7 +426,7 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::none().and_machine(machine),
server::Services::with_machine(machine),
)
});
}
@@ -93,7 +93,7 @@ fn stdio_host() -> (SharedHost, TempSandbox) {
// bridge to *that* would be testing their machine's state instead of
// this build.
.args(["--stdio", "--serve"])
// The server opens its workspace store at startup. None of these cases
// The server opens its machine tree at startup. None of these cases
// touch it, but pointing it at the sandbox keeps forty-six child
// processes off the developer's real `~/.local/share/tty7`.
.env("TTY7_DATA_DIR", sandbox.path())
-543
View File
@@ -1,543 +0,0 @@
//! The workspace store, end to end against a real `tty7-server` child process.
//!
//! Same shape and the same reasoning as [`stdio_conformance`]: the client is
//! the shipped `ControlClient`, the wire is the control dialect over real
//! pipes, and the server is the shipped binary keeping its records in a file it
//! owns. What this file adds is the half the conformance suite cannot reach —
//! the store is not a `Host` method, so no amount of `read_dir` parity proves
//! that `WorkspacePut` reached a disk or that another client heard about it.
//!
//! The three things worth a process boundary:
//!
//! | | Why an in-process socket pair would not do |
//! |---|---|
//! | The record is on **the server's** disk | The whole storage split is "the machine is the authority". A store in the test's own address space proves nothing about that |
//! | `workspace-store` is advertised only when served | The capability bit is built from what the *binary* wires up, and that wiring lives in `main.rs` |
//! | A change reaches the **other** connection | Two clients, one server process, one file — the configuration the user actually has when their laptop and their desktop are both connected |
//!
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
//! another's leftovers and nothing here can touch the developer's real
//! `~/.local/share/tty7/workspaces.json`.
// Unix-only, for the same reason as `stdio_conformance.rs`: the server under
// test is a `--stdio` child, and two of the cases stand 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::workspace_store::{STORE_FILE, WorkspaceStore};
use tty7_core::daemon::control::{
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, feature,
};
use tty7_core::host::local::LocalHost;
use tty7_core::host::server;
/// The child, and the only way to end it — see `stdio_conformance.rs` for why a
/// `LinkShutdown` is what reaps a process-backed link.
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 to
/// it.
struct Client {
control: ControlClient,
events: Arc<Mutex<Vec<ControlEvent>>>,
peer_features: Vec<String>,
}
impl Client {
/// Wait for a `WorkspaceChanged` naming `id`, or fail saying what did
/// arrive. Polled rather than blocked on a channel because the event and
/// the reply that caused it race by construction.
fn expect_changed(&self, id: &str) {
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::WorkspaceChanged { id: got } if got == id))
{
return;
}
assert!(
Instant::now() < deadline,
"no WorkspaceChanged for {id}; saw {seen:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
/// Wait for the takeover notice naming `workspace` and `by`.
fn expect_preempted(&self, workspace: &str, by: &str) {
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::Preempted { workspace: w, by: b }
if w == workspace && b == by)
}) {
return;
}
assert!(
Instant::now() < deadline,
"no Preempted for {workspace} by {by}; saw {seen:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn changed_count(&self) -> usize {
self.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|e| matches!(e, ControlEvent::WorkspaceChanged { .. }))
.count()
}
}
/// Start a `tty7-server --stdio --serve` whose store lives in `data_dir`, and
/// connect a client to it.
///
/// `--serve` rather than letting the mode be probed: a developer running these
/// tests may well have a real `tty7-server --daemon` up, and bridging to *that*
/// would be testing their machine's state — and, here, writing to their real
/// workspace file.
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 store_file(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join(STORE_FILE)
}
fn record(id: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"name": name,
"session": {"active": 0, "tabs": [
{"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 11}},
"sidebar_group": "/home/me/proj"}
]},
"last_active": 1_753_600_000u64,
})
}
fn json(reply: ReplyOk) -> serde_json::Value {
match reply {
ReplyOk::Json(v) => v,
other => panic!("expected a Json reply, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
/// The capability bit is the client's cue that asking is worth a round trip, so
/// it has to reflect what the shipped binary actually wired up.
#[test]
fn the_server_advertises_the_workspace_store() {
let dir = data_dir();
let client = connect(dir.path(), "cap");
assert!(
client
.peer_features
.iter()
.any(|f| f == feature::WORKSPACE_STORE),
"features were {:?}",
client.peer_features
);
}
/// **The milestone's proof for M5.** The four RPCs against a real server, and
/// the record ends up in a file that server owns — the storage split is not a
/// diagram, it is this file on that machine.
#[test]
fn records_survive_in_a_file_the_server_owns() {
let dir = data_dir();
let client = connect(dir.path(), "rpc");
assert_eq!(
json(client.control.call(ControlRequest::WorkspaceList).unwrap()),
serde_json::json!([])
);
for (id, name) in [("w-api", "api"), ("w-web", "web")] {
client
.control
.call(ControlRequest::WorkspacePut {
id: id.to_string(),
json: record(id, name),
})
.expect("put");
}
// The file is on this machine only because the "remote" is this machine;
// the point is that the *test process* never wrote it. Reading it with
// plain `std::fs` is how we know the bytes went out through a pipe and came
// back as a syscall someone else made.
let text = std::fs::read_to_string(store_file(&dir)).expect("the server wrote its store");
assert!(text.contains("w-api"), "{text}");
assert!(text.contains("w-web"), "{text}");
// Get answers exactly what was put.
let got = json(
client
.control
.call(ControlRequest::WorkspaceGet {
id: "w-api".to_string(),
})
.unwrap(),
);
assert_eq!(got, record("w-api", "api"));
// List answers both, in the order they were written.
let listed = json(client.control.call(ControlRequest::WorkspaceList).unwrap());
let ids: Vec<&str> = listed
.as_array()
.unwrap()
.iter()
.map(|v| v["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["w-api", "w-web"]);
// A missing id is an error the client can tell from an empty record.
let missing = client
.control
.call(ControlRequest::WorkspaceGet {
id: "not-a-workspace".to_string(),
})
.unwrap_err();
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
// Delete reaches the disk, and deleting again is still success.
for _ in 0..2 {
client
.control
.call(ControlRequest::WorkspaceDelete {
id: "w-api".to_string(),
})
.expect("delete");
}
let text = std::fs::read_to_string(store_file(&dir)).unwrap();
assert!(!text.contains("w-api"), "{text}");
assert!(text.contains("w-web"), "{text}");
}
/// A second connection to the same server sees the first one's records — that
/// is what "换台电脑连过来要看到同一份" means once the machine is fixed and the
/// client is not.
#[test]
fn a_later_client_sees_what_an_earlier_one_wrote() {
let dir = data_dir();
{
let first = connect(dir.path(), "first");
first
.control
.call(ControlRequest::WorkspacePut {
id: "w".to_string(),
json: record("w", "api"),
})
.expect("put");
first.control.close();
}
// A brand-new server process, reading the file the previous one left.
let second = connect(dir.path(), "second");
let got = json(
second
.control
.call(ControlRequest::WorkspaceGet {
id: "w".to_string(),
})
.unwrap(),
);
assert_eq!(got["name"], "api");
assert_eq!(got["session"]["tabs"][0]["pane"]["Leaf"]["pane_id"], 11);
}
/// Two clients on one machine at once. A change by one has to reach the other,
/// and must not come back to its author.
///
/// The store lives behind a listener, as it does under `--daemon`, and both
/// clients reach it as `--stdio --bridge` children — the same two-hop shape
/// `cli.rs` uses, and the configuration a user has when their laptop and their
/// desktop are both connected. A store per connection would pass every other
/// test in this file and fail this one.
#[test]
fn a_change_from_one_client_reaches_the_other() {
let dir = data_dir();
let store = WorkspaceStore::open(store_file(&dir));
let sock = dir.path().join("control.sock");
let listener = server::bind_control_socket(&sock).unwrap();
{
let store = Arc::clone(&store);
std::thread::spawn(move || {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::with_workspaces(store),
)
});
}
let writer = bridged(&sock, "writer");
let watcher = bridged(&sock, "watcher");
assert!(
writer
.peer_features
.iter()
.any(|f| f == feature::WORKSPACE_STORE)
);
writer
.control
.call(ControlRequest::WorkspacePut {
id: "shared".to_string(),
json: record("shared", "api"),
})
.expect("put");
watcher.expect_changed("shared");
assert_eq!(
writer.changed_count(),
0,
"a client must not be pushed its own change"
);
// The watcher is looking at the same store, not at a copy.
let got = json(
watcher
.control
.call(ControlRequest::WorkspaceGet {
id: "shared".to_string(),
})
.unwrap(),
);
assert_eq!(got["name"], "api");
// A delete is a change too — and the watcher's own delete comes back to the
// writer, which is the same rule seen from the other side.
watcher
.control
.call(ControlRequest::WorkspaceDelete {
id: "shared".to_string(),
})
.expect("delete");
writer.expect_changed("shared");
assert_eq!(watcher.changed_count(), 1, "still only the writer's put");
assert_eq!(store.len(), 0);
}
/// **The takeover, across two real processes.**
///
/// The same two-client shape as the change-notification test, and for the same
/// reason: a takeover is by definition something one connection does to
/// *another*, so an in-process registry with two handles into it would prove
/// only that the data structure works. What has to hold is that the notice
/// crosses a pipe into a different program and that the displaced link actually
/// closes.
///
/// D8 is the assertion in the middle: the newcomer holds the workspace
/// afterwards. Rejecting the second client would satisfy "only one at a time"
/// just as well and is the decision this test exists to rule out.
#[test]
fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() {
let dir = data_dir();
let store = WorkspaceStore::open(store_file(&dir));
let sock = dir.path().join("control.sock");
let listener = server::bind_control_socket(&sock).unwrap();
{
let store = Arc::clone(&store);
std::thread::spawn(move || {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::with_workspaces(store),
)
});
}
let laptop = bridged_for(&sock, "tok-laptop", "laptop", Some("w"));
// The attach runs on the server thread after the handshake reply, so the
// record is what says it happened — not the fact that we got a `HELLO_OK`.
await_attachment(&store, "w", "laptop");
assert!(laptop.control.call(ControlRequest::Ping).is_ok());
let desktop = bridged_for(&sock, "tok-desktop", "desktop", Some("w"));
// The displaced client is told which workspace it lost and to whom.
laptop.expect_preempted("w", "desktop");
// …and then its link is closed, because this connection existed for that
// workspace: the server closes its stream.
let deadline = Instant::now() + Duration::from_secs(10);
while laptop.control.is_connected() {
assert!(
Instant::now() < deadline,
"the displaced session's link stayed open"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
laptop
.control
.call(ControlRequest::Ping)
.unwrap_err()
.kind(),
io::ErrorKind::ConnectionReset
);
// D8: the newcomer is the one holding it, and it can still work.
await_attachment(&store, "w", "desktop");
assert!(desktop.control.call(ControlRequest::Ping).is_ok());
assert_eq!(
desktop.changed_count(),
0,
"taking over is not a workspace change"
);
// Taking it back is the same operation in the other direction — that is all
// the [Take Back] button is.
let back = bridged_for(&sock, "tok-laptop-2", "laptop", None);
let reply = back
.control
.call(ControlRequest::WorkspaceAttach { id: "w".into() })
.expect("attach");
assert_eq!(
reply,
ReplyOk::Attached {
took_over_from: Some("desktop".to_string())
}
);
desktop.expect_preempted("w", "laptop");
await_attachment(&store, "w", "laptop");
// The link that just did the taking was not opened *for* the workspace, so
// it is a plain machine connection and keeps working — that is the shape the
// GUI has, one link per machine.
assert!(back.control.is_connected());
assert!(back.control.call(ControlRequest::Ping).is_ok());
}
/// Poll until `hostname` holds `workspace`, or fail saying who does.
fn await_attachment(store: &Arc<WorkspaceStore>, workspace: &str, hostname: &str) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let who = store.attachment(workspace);
if who.as_ref().map(|a| a.hostname.as_str()) == Some(hostname) {
return;
}
assert!(
Instant::now() < deadline,
"{workspace} is held by {who:?}, not {hostname}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
/// A `--stdio --bridge` child connected to an already-listening control socket.
fn bridged(sock: &Path, token: &str) -> Client {
bridged_for(sock, token, "test-client", None)
}
/// [`bridged`], naming the client machine and, optionally, the workspace this
/// connection is opened *for* — the hello field the takeover keys on.
fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client {
let hello = ControlHello {
control_version: tty7_core::daemon::control::CONTROL_VERSION,
workspace: workspace.map(str::to_string),
client_token: token.to_string(),
client_hostname: hostname.to_string(),
};
bridged_with(sock, hello)
}
fn bridged_with(sock: &Path, hello: ControlHello) -> Client {
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,
}
}
+3 -13
View File
@@ -1,7 +1,7 @@
//! The window's half of "Connect to Host".
//!
//! [`ui::remote_connect`](crate::ui::remote_connect) is the plumbing — SSH
//! specs, routed control connections, the remote workspace store. This is the
//! specs, routed control connections, the remote machine's tree. This is the
//! part that lives on a window: the state the home page renders, the steps that
//! move between those states, and the guards that keep a window on one machine.
//!
@@ -1440,10 +1440,6 @@ pub(crate) fn drain_events(cx: &mut gpui::App) {
release_panes(cx, id);
cx.refresh_windows();
}
// The retired record store's change notice. Nothing writes those
// records any more; the tree's Layout deltas below carry the same
// news with the change itself.
ControlEvent::WorkspaceChanged { .. } => {}
// Another writer edited a workspace tree this client shows: apply
// the delta to the mirror and the live window (or re-pull the
// workspace when it will not apply cleanly).
@@ -1545,7 +1541,7 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) {
log::info!("took workspace {key} back from {who}");
}
Ok(_) => {}
// A machine that has no workspace store (an older
// A machine that has no machine tree (an older
// server) still serves files; the workspace is usable,
// it simply cannot be claimed exclusively.
Err(e) => log::warn!("could not attach to workspace {key}: {e}"),
@@ -2153,14 +2149,8 @@ mod tests {
assert_eq!(q.waiting(), 1);
}
// ── `WorkspaceChanged` → re-read (B3's push, arriving) ───────────────────
// ── The input gate ───────────────────────────────────────────────────────
/// **A burst of changes costs one round trip per workspace.**
///
/// B3's contract for this event is that it means only "read it again", so
/// losing one is safe and getting ten is safe. Collapsing them is the whole
/// of the logic that rule buys — and the thing that keeps a client with a
/// chatty peer from opening a `WorkspaceGet` per keystroke of theirs.
#[test]
fn every_state_says_what_it_means_for_the_keyboard() {
let cases = [