mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
Generated
-1
@@ -9606,7 +9606,6 @@ dependencies = [
|
||||
name = "tty7-server"
|
||||
version = "26.7.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tty7-core",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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:?}"),
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+159
-567
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user