diff --git a/.github/scripts/assert-static.sh b/.github/scripts/assert-static.sh index 8c4cca95..59d553cf 100755 --- a/.github/scripts/assert-static.sh +++ b/.github/scripts/assert-static.sh @@ -3,8 +3,8 @@ # Fail unless the binary is a fully static ELF — no dynamic loader, no shared # library dependencies. # -# This is the mechanical guard behind D10 (docs/2026-07-27-remote-workspace-design.md): -# one `tty7-server` binary is pushed to arbitrary remote machines and must run +# This is the mechanical guard behind decision D10: one `tty7-server` binary +# is pushed to arbitrary remote machines and must run # there regardless of what libc, and what *version* of it, that machine has. A # build that silently picked up a dynamic dependency would still pass a # compile-only CI job and then fail on the first old box a user connects to — diff --git a/.github/scripts/bundle-windows.ps1 b/.github/scripts/bundle-windows.ps1 index f57cbb16..e82a8a0c 100644 --- a/.github/scripts/bundle-windows.ps1 +++ b/.github/scripts/bundle-windows.ps1 @@ -27,7 +27,7 @@ Copy-Item LICENSE "$Stage/LICENSE.txt" Copy-Item README.md "$Stage/README.md" # The Linux musl `tty7-server`, staged at server/ so a WSL distro can be handed -# the binary this client shipped with (design §12: WSL downloads nothing). The +# the binary this client shipped with (WSL downloads nothing). The # lookup path is a contract with `daemon::install::wsl` — it searches # /server/ first — so this directory name is not free to # change on its own. Missing is a warning, not an error, matching `server-musl`'s diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh index 51eb7149..b1dc7216 100755 --- a/.github/scripts/check-host-boundary.sh +++ b/.github/scripts/check-host-boundary.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # -# Contract §10.6 — the GUI must not touch the filesystem or git directly. +# The GUI must not touch the filesystem or git directly. # # Once a workspace can live on a remote machine, a path held by `ui::` or # `terminal::` is not necessarily a path on *this* box, and `std::path`'s -# fs-backed APIs quietly answer for the wrong machine (contract §4.3): +# fs-backed APIs quietly answer for the wrong machine: # `canonicalize` walks the local filesystem, `is_absolute` says `false` for # `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may # be looking at a workspace path has to go through `ui::host_ops` / the `Host` @@ -151,7 +151,7 @@ if [ "$violations" -ne 0 ]; then cat >&2 <<'EOF' -------------------------------------------------------------------------------- -Contract §10.6: the GUI reached the filesystem/git directly. +The GUI reached the filesystem/git directly. A path in `ui::` or `terminal::` may belong to a remote workspace, where these calls answer for the wrong machine. Route it through `ui::host_ops` / `Host` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86a803f9..fb645354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,9 @@ jobs: components: rustfmt - run: cargo fmt --check - # Contract §10.6: `ui::` and `terminal::` must not reach the filesystem or git + # `ui::` and `terminal::` must not reach the filesystem or git # directly, because once a workspace can be remote those calls answer for the - # wrong machine (§4.3). The allowlist of genuinely-local paths lives in the + # wrong machine. The allowlist of genuinely-local paths lives in the # script, next to the reason each one is exempt. # # A standalone job on purpose, and one that must stay *non-required*: main's @@ -34,7 +34,7 @@ jobs: # `server-musl` below. Cheap enough (a checkout and a grep) that it does not # need caching or a toolchain. host-boundary: - name: host boundary (§10.6) + name: host boundary runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -94,7 +94,7 @@ jobs: run: cargo test --locked --target ${{ matrix.target }} # Static musl builds of the headless server binary that remote workspaces push - # onto the far machine (docs/2026-07-27-remote-workspace-design.md, D10/§12). + # onto the far machine (decision D10). # One binary has to run on any distro without regard to the target's glibc # version, so it is linked fully static against musl rather than built per # distro. Compile-only — this job publishes nothing; release.yml and @@ -103,8 +103,7 @@ jobs: # Deliberately a *separate* job rather than two more rows in the `build` matrix # above: those three `build & test ()` names are main's required # checks, and reshaping that matrix would wedge branch protection on every open - # PR. Keep this job non-required until it has a few weeks of green — see - # docs/remote-server-assets.md. + # PR. Keep this job non-required until it has a few weeks of green. server-musl: name: tty7-server musl (${{ matrix.target }}) runs-on: ubuntu-latest @@ -153,7 +152,7 @@ jobs: with: key: ${{ matrix.target }} - # The crate split (§11) lands separately; until `tty7-server` exists as a + # The crate split lands separately; until `tty7-server` exists as a # workspace member this job has nothing to build. Skip cleanly rather than # fail, so the workflow can land before the split and simply start working # once it arrives. `--no-deps` keeps this to a manifest parse — no @@ -167,7 +166,7 @@ jobs: echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "::notice::tty7-server is not a workspace member yet (crate split, design §11 / M1) — nothing to build" + echo "::notice::tty7-server is not a workspace member yet (crate split, M1) — nothing to build" fi # `-p tty7-server` addresses the package by name, so this survives whatever diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4a115ed5..43baa178 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -167,7 +167,7 @@ jobs: if-no-files-found: error # Mirrors release.yml's server-musl job; keep the two in sync when editing. - # Nightly carries the server binaries too so the remote-install path (§12) can + # Nightly carries the server binaries too so the remote-install path can # be exercised against the rolling channel instead of waiting for a tag. server-musl: needs: plan @@ -272,7 +272,7 @@ jobs: path: dist merge-multiple: true - # Same contract as release.yml — see docs/remote-server-assets.md. Written + # Same contract as release.yml — see `install::asset`. Written # into dist/ before the upload below so it ships as an asset like any # other, and so the prune step at the end sees it as current. - name: Generate checksums.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f1fe16e..5fbc5f20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: build: # The Windows installer embeds the Linux musl `tty7-server` so a WSL distro # can be served the binary the client already shipped with, instead of - # downloading one (design §12: WSL installs nothing over the network). That + # downloading one (WSL installs nothing over the network). That # binary comes from `server-musl`, so the two jobs can no longer run in # parallel. Serialising all four platforms behind it costs a few minutes on # a release — cheap next to splitting the Windows entry into its own job and @@ -150,10 +150,11 @@ jobs: if-no-files-found: error # The headless server binary remote workspaces install on the far machine - # (design doc D10/§12). Statically linked against musl so a single binary runs + # (decision D10). Statically linked against musl so a single binary runs # on any distro whatever its glibc vintage, and shipped as a bare executable # rather than an archive so the client can fetch exactly one file and verify it - # against checksums.txt. Asset naming contract: docs/remote-server-assets.md. + # against checksums.txt. The asset names are a contract with the installer: + # `tty7_core::daemon::install::asset` derives them from `uname -sm`. # # Separate from the `build` matrix above because it shares nothing with it: no # GUI toolchain, no bundling, no code signing, two targets off one runner. @@ -193,7 +194,7 @@ jobs: workspaces: tty7 key: ${{ matrix.target }} - # Until the crate split (§11) lands there is no tty7-server to build. Skip + # Until the crate split lands there is no tty7-server to build. Skip # rather than fail, so this workflow can ship ahead of the split; the # release simply carries no server assets until it arrives. - name: Look for the tty7-server package @@ -224,7 +225,7 @@ jobs: run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server" # Flat, version-free asset name — the tag in the download URL carries the - # version. See docs/remote-server-assets.md for the contract the client + # version. See `install::asset` for the contract the client # installer derives this name from. - name: Stage the asset if: steps.probe.outputs.present == 'true' @@ -262,12 +263,12 @@ jobs: merge-multiple: true # sha256 over every asset, so the remote-server installer can verify what - # it downloaded before writing it to someone else's machine (design §16 — - # a mismatch aborts the install outright). Generated here rather than in + # it downloaded before writing it to someone else's machine (a mismatch + # aborts the install outright). Generated here rather than in # the build jobs because only this job sees the complete asset set, and a # per-job fragment would have to be concatenated in a deterministic order # anyway. GNU coreutils format (" "), bare filenames, sorted — - # see docs/remote-server-assets.md for the format the client parses. + # see `install::checksums` for the format the client parses. - name: Generate checksums.txt run: | set -euo pipefail diff --git a/Cargo.toml b/Cargo.toml index e54f107b..c81f0504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,7 @@ path = "src/main.rs" [dependencies] # The framework-free half of tty7: wire protocol, session daemon, PTY, the # native SSH engine, and the domain model the headless `tty7-server` shares with -# this GUI. Everything that does *not* need gpui lives there — see -# `docs/2026-07-27-remote-workspace-design.md` §11. +# this GUI. Everything that does *not* need gpui lives there. # `gssapi` is off in tty7-core's defaults (a static musl `tty7-server` cannot # link the system krb5 it binds); the GUI, which builds against a real desktop # toolchain, turns it on so managed SSH connections keep offering @@ -25,7 +24,7 @@ path = "src/main.rs" # # `remote-install` is off there for the same shape of reason: it pulls an HTTPS # client used only to *download* a `tty7-server` onto a remote machine -# (design §12, D5). The server binary is the thing being downloaded, so it can +# (decision D5). The server binary is the thing being downloaded, so it can # never take that path; the GUI, which is the client that pushes it, can. tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] } diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index 34b32e8a..bc262270 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -10,7 +10,7 @@ publish = false # The whole point of this crate is that it does *not* depend on gpui. Everything # here has to compile and run on a headless Linux box (that is what # `tty7-server` is), so nothing windowing-, rendering- or GUI-shaped belongs in -# these dependencies — see `docs/2026-07-27-remote-workspace-design.md` §11. +# these dependencies. [dependencies] anyhow.workspace = true log.workspace = true @@ -155,7 +155,7 @@ tokio = { version = "1", features = ["test-util", "macros", "rt"] } # unchanged. It has to be optional because `libgssapi` binds the *system* MIT / # Heimdal krb5 through bindgen: a machine without krb5 headers cannot build it # at all, and a static musl `tty7-server` — the binary remote workspaces push -# onto arbitrary hosts (design §12, D10) — cannot link it under any +# onto arbitrary hosts (decision D10) — cannot link it under any # circumstances. Without the feature, `SshAuthMode::Gssapi` reports that the # method is unavailable in this build and the other auth families are untouched. # @@ -170,7 +170,7 @@ tokio = { version = "1", features = ["test-util", "macros", "rt"] } default = [] gssapi = ["dep:libgssapi"] # Lets this build download a `tty7-server` release asset over HTTPS and push it -# onto a remote machine (design §12). Off by default so `tty7-server` — which is +# onto a remote machine. Off by default so `tty7-server` — which is # the thing being downloaded, and never the thing doing the downloading — links # no HTTP client. Without it, `daemon::install` still installs from bytes it is # handed and still launches/probes a remote daemon; only the fetch fails, with a diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 0bfae6f5..aa517f36 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1513,6 +1513,9 @@ mod tests { fn git(&self, cwd: &Path, args: &[&str]) -> io::Result { self.0.git(cwd, args) } + fn shells(&self) -> io::Result { + self.0.shells() + } fn watch(&self, dirs: &[PathBuf]) -> io::Result { self.0.watch(dirs) } diff --git a/crates/tty7-core/src/core/keychain.rs b/crates/tty7-core/src/core/keychain.rs index b0723742..ed341421 100644 --- a/crates/tty7-core/src/core/keychain.rs +++ b/crates/tty7-core/src/core/keychain.rs @@ -20,7 +20,7 @@ //! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on //! boxes that have no OS keychain at all. Keeping `keyring` out of this crate's //! manifest is what keeps a static `tty7-server` from linking the whole -//! `zbus`/`secret-service` stack it can never use — see the design doc §11. +//! `zbus`/`secret-service` stack it can never use. //! //! What has to stay is exactly what `Config` needs to parse `config.json` //! identically on the server: the account-naming scheme and [`CredentialRef`]. diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 7c2a7316..8efe0e6d 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -93,8 +93,8 @@ pub struct SessionTab { /// is deliberately not persistable. The qualifier is not missing, it is /// factored out: a tab always belongs to exactly one [`Workspace`], a /// workspace names exactly one machine in [`Workspace::host`], and a - /// window shows exactly one workspace (design §2, and §3's "一个窗口里既 - /// 有本地又有远程 —— 这个**永远不做**"). So the fully-qualified group key + /// window shows exactly one workspace — mixing local and remote tabs in one + /// window is the thing tty7 never does. So the fully-qualified group key /// is `(workspace.host_id(), tab.sidebar_group)`, with the host half /// stored once per workspace instead of once per tab. Two machines whose /// repos share a root path can only collide inside one window, which the @@ -153,14 +153,14 @@ impl std::fmt::Display for WorkspaceId { /// The machine a remote workspace lives on, named the way the user already /// named it. /// -/// **This is a pointer, never a configuration.** Design §2 is explicit that a +/// **This is a pointer, never a configuration.** It is a hard rule that a /// machine is configured once and that remote workspaces reuse what is already /// there — the profile's keys, its jump host, its `ProxyCommand` — so this type /// has exactly one job: say *which* existing entry to connect through. The /// three variants are the three places an SSH target can already have been /// spelled out in tty7 today. /// -/// | Variant | Where it came from | Connection key (contract §4.2) | +/// | Variant | Where it came from | Connection key | /// |---|---|---| /// | [`Profile`](RemoteTarget::Profile) | A saved [`SshProfile`](crate::core::ssh_profile::SshProfile), by its stable uuid | `ssh-profile:` | /// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:` | @@ -192,7 +192,7 @@ pub enum RemoteTarget { }, /// A WSL distribution. **M8 owns the behaviour**; the variant exists now so /// that [`connection_key`](RemoteTarget::connection_key) is a total function - /// over contract §4.2's table rather than one that grows a case later. + /// over the table rather than one that grows a case later. Wsl { distro: String }, /// A `tty7-server --stdio` child process on *this* machine — the workspace /// mirror of [`RouteTarget::LocalStdio`](crate::daemon::router::RouteTarget::LocalStdio), @@ -245,12 +245,11 @@ impl RemoteTarget { )) } - /// The canonical connection string this target hashes to (contract §4.2). + /// The canonical connection string this target hashes to. /// /// **Contains no workspace id.** Several workspaces on one box share a key, /// and therefore share a [`HostId`](crate::host::HostId) and the one SSH - /// connection underneath it — the granularity the whole design assumes - /// (design §10). + /// connection underneath it — the granularity the whole design assumes. /// /// One conservative case worth knowing: `me@box` and a bare `box` are /// different keys even when the client's SSH would resolve them to the same @@ -323,7 +322,7 @@ impl std::fmt::Display for RemoteTarget { /// into that machine's `~/.local/share/tty7/workspaces.json` /// ([`crate::core::workspace_store`]). A client-side [`Workspace`] carrying one /// of these is a *view*, not the record: its `session` is left empty until the -/// layout is pulled from the remote, which owns it (design §10). +/// layout is pulled from the remote, which owns it. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct RemoteRef { /// Which machine, in terms of a configuration that already exists. @@ -381,7 +380,7 @@ pub struct Workspace { /// The machine this workspace's panes and files live on. `None` means this /// one, **and means it identically to every build that predates the field**: /// a `session.json` written before this existed decodes with `None` - /// throughout, i.e. all-local, which is the behaviour it had (design §10). + /// throughout, i.e. all-local, which is the behaviour it had. /// /// A `Some` entry is a *view* of a record that lives over there. Its /// `session` is empty until the layout is pulled from the remote's own @@ -390,6 +389,32 @@ pub struct Workspace { /// workspace from the laptop at home. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: Option, + /// Identity of the daemon *process* the pane ids in `session` refer to + /// (see `daemon::protocol::DaemonVersion::instance`). One field for the + /// whole workspace, not one per leaf, because a workspace's panes all live + /// in one daemon (one window, one machine). + /// + /// This is what makes a saved pane id safe to trust: daemon ids restart + /// from 1, so after a reboot every saved id points at whatever unrelated + /// shell happens to hold the number now — and restore's aliveness check + /// cannot tell a survivor from a squatter. A claim whose instance differs + /// from the daemon now serving blanks its ids instead + /// ([`Workspace::forget_stale_pane_ids`]) and takes the fresh-spawn path, + /// agent resume included, which is the correct reading of "the daemon + /// those panes lived in is gone". + /// + /// A remote workspace records its machine's `tty7-server` instance here, + /// for exactly the same reason and read by exactly the same check. The live + /// per-connection tracking on the client (`note_instance`) does not replace + /// this: that map is in memory, so it is empty on the launch where it would + /// matter most — the one after a client restart that spanned a server + /// replacement. + /// + /// `None` for records written before the field, and whenever the serving + /// process cannot be named (an older peer, a machine not connected). `None` + /// disables the check, never fails it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daemon_instance: Option, } impl Default for Workspace { @@ -402,6 +427,7 @@ impl Default for Workspace { open: true, last_active: now_secs(), host: None, + daemon_instance: None, } } } @@ -488,6 +514,48 @@ impl Workspace { out } + /// Drop every saved pane id, keeping the layout. Answers how many were + /// dropped, so a caller with nothing to forget can skip the write. + /// + /// For the one caller that *knows* the panes are gone: ending a workspace's + /// sessions kills them and then leaves the record on file to be reopened. + /// The ids in it are ours to invalidate — we are what killed them — and a + /// leaf with no id is exactly what restore needs to see, because that is + /// the path that spawns a fresh shell in the saved cwd and hands a coding + /// agent its `--resume`. Left in place they are a promise the machine + /// cannot keep: the reattach finds nothing, and on a remote workspace it + /// used to have no way to say so. + pub fn forget_pane_ids(&mut self) -> usize { + let mut forgotten = 0; + for tab in &mut self.session.tabs { + forgotten += blank_pane_ids(&mut tab.pane); + } + forgotten + } + + /// Blank every saved pane id if it was recorded against a *different* + /// daemon process than `current` — see [`Workspace::daemon_instance`] for + /// the id-reuse failure this closes. Answers how many ids were dropped. + /// + /// Only a **known, differing** instance pair trips it. `None` on either + /// side means "cannot tell" (an old record, an old daemon), and treating + /// that as stale would respawn every pane on the first launch after an + /// upgrade — exactly the sessions persistence exists to keep. + /// + /// The agent fields stay, deliberately: unlike a *duplicate* claim (see + /// `drop_duplicate_pane_ids`), a stale-instance claim means the pane is + /// genuinely gone with its daemon, nothing else is running the + /// conversation, and the fresh shell resuming it is the feature. + pub fn forget_stale_pane_ids(&mut self, current: Option<&str>) -> usize { + let (Some(recorded), Some(current)) = (self.daemon_instance.as_deref(), current) else { + return 0; + }; + if recorded == current { + return 0; + } + self.forget_pane_ids() + } + /// Stamp this workspace as just-focused. pub fn touch(&mut self) { self.last_active = now_secs(); @@ -531,7 +599,7 @@ impl Workspace { /// The record the **remote** owns, as the JSON that crosses the wire in a /// [`WorkspacePut`](crate::daemon::control::ControlRequest::WorkspacePut). /// - /// Design §10's storage split, executable rather than aspirational: what + /// The storage split, executable rather than aspirational: what /// stays here is `window`, `open` and `host` — this client's view state — /// and what goes over there is everything that is a fact about the machine. /// [`REMOTE_OWNED_FIELDS`] pins the split, and a test fails if a new field @@ -559,7 +627,7 @@ impl Workspace { } } -/// The `Workspace` fields the **remote** is the authority for (design §10). +/// The `Workspace` fields the **remote** is the authority for. /// Everything else is client-side view state and never leaves this machine. /// /// A `Workspace` field that is in neither list is a bug: it would be dropped by @@ -569,7 +637,11 @@ impl Workspace { pub const REMOTE_OWNED_FIELDS: &[&str] = &["id", "name", "session", "last_active"]; /// The client-side view state, which stays in this machine's `session.json`. -pub const CLIENT_OWNED_FIELDS: &[&str] = &["window", "open", "host"]; +/// `daemon_instance` is client-owned because it records **which serving process +/// this client last saw** — an observation, not a property of the workspace. Two +/// clients open on one remote workspace each keep their own, and neither may +/// overwrite the other's; a remote record that carried it would do exactly that. +pub const CLIENT_OWNED_FIELDS: &[&str] = &["window", "open", "host", "daemon_instance"]; /// The remote-owned half of a [`Workspace`], for reading a record back. /// @@ -804,18 +876,45 @@ fn collect_pane_ids(pane: &SessionPane, out: &mut Vec) { } } +/// Blank every leaf's `pane_id` under `pane`, answering how many were set. +/// See [`Workspace::forget_pane_ids`]. +pub fn blank_pane_ids(pane: &mut SessionPane) -> usize { + match pane { + SessionPane::Leaf { pane_id, .. } => usize::from(pane_id.take().is_some()), + SessionPane::Split { a, b, .. } => blank_pane_ids(a) + blank_pane_ids(b), + } +} + /// Blank any `pane_id` already claimed by an earlier-visited workspace. A /// blanked leaf still restores — it just spawns a fresh shell in its saved cwd, /// the same path a session from before the daemon existed takes. +/// +/// The agent resume fields go with it. A blanked leaf takes restore's +/// spawn-fresh path, and that path auto-types the agent's resume command — +/// but the pane this claim duplicated is still running that very agent under +/// its winning workspace, so "recovering" the loser would start a second +/// process on the same agent session id. The duplicate claim is the evidence +/// of a corrupted record, not of a lost conversation; the conversation lives +/// with the winner. fn drop_duplicate_pane_ids( pane: &mut SessionPane, seen: &mut std::collections::HashSet, ) -> usize { match pane { - SessionPane::Leaf { pane_id, .. } => match *pane_id { + SessionPane::Leaf { + pane_id, + agent_session_id, + agent_launch_argv, + .. + } => match *pane_id { Some(id) if !seen.insert(id) => { - log::warn!("workspace claims pane {id} twice; dropping the duplicate claim"); + log::warn!( + "workspace claims pane {id} twice; dropping the duplicate claim \ + (and its agent resume, which the winning claim still owns)" + ); *pane_id = None; + *agent_session_id = None; + *agent_launch_argv = None; 1 } _ => 0, @@ -1116,6 +1215,110 @@ mod tests { assert_eq!(back.active, Some(id)); } + /// Ending a workspace's sessions leaves the layout and drops the ids — the + /// cwds are what reopening rebuilds from, and a kept id would send restore + /// down the reattach path to a pane that no longer exists. + #[test] + fn forgetting_pane_ids_keeps_the_layout_and_the_cwds() { + let mut ws = workspace(vec![ + tab( + SessionPane::Split { + axis: SessionAxis::Horizontal, + ratio: 0.5, + a: Box::new(leaf(Some("/work"), Some(1))), + b: Box::new(leaf(Some("/work/api"), Some(2))), + }, + Some("/work"), + ), + tab(leaf(Some("/tmp"), None), None), + ]); + + assert_eq!( + ws.forget_pane_ids(), + 2, + "only the claims that existed count" + ); + assert!(ws.pane_ids().is_empty()); + assert_eq!(ws.session.tabs.len(), 2, "the tabs are what survives"); + assert_eq!(ws.pane_count(), 3, "and so is the split"); + assert_eq!( + ws.first_cwd(), + Some(PathBuf::from("/work")), + "reopening respawns in the saved directory, so it must still be there" + ); + assert_eq!( + ws.forget_pane_ids(), + 0, + "a second pass has nothing to do, so the caller can skip its write" + ); + } + + /// The stale-instance check: ids recorded against a *different* daemon + /// process are blanked (they now name unrelated shells at best), ids + /// recorded against the *same* one are kept, and an unknown on either side + /// changes nothing — treating "cannot tell" as stale would respawn every + /// pane on the first launch after an upgrade. + #[test] + fn stale_instance_blanks_pane_ids_and_matching_or_unknown_keeps_them() { + let fresh = |instance: Option<&str>| { + let mut ws = workspace(vec![tab(leaf(Some("/work"), Some(7)), None)]); + ws.daemon_instance = instance.map(str::to_string); + ws + }; + + let mut ws = fresh(Some("daemon-a")); + assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 1); + assert!(ws.pane_ids().is_empty()); + assert_eq!( + ws.first_cwd(), + Some(PathBuf::from("/work")), + "the layout survives; only the claims go" + ); + + let mut ws = fresh(Some("daemon-a")); + assert_eq!(ws.forget_stale_pane_ids(Some("daemon-a")), 0); + assert_eq!(ws.pane_ids(), vec![7], "same process, ids stay attachable"); + + let mut ws = fresh(None); + assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 0); + assert_eq!(ws.pane_ids(), vec![7], "an old record is not judged"); + + let mut ws = fresh(Some("daemon-a")); + assert_eq!(ws.forget_stale_pane_ids(None), 0); + assert_eq!(ws.pane_ids(), vec![7], "an unknown daemon is not judged"); + } + + /// Unlike a duplicate claim, a stale-instance claim keeps its agent resume: + /// the daemon those panes lived in is gone, nothing else runs the + /// conversation, and the fresh shell resuming it is the feature working. + #[test] + fn stale_instance_keeps_the_agent_resume() { + let mut ws = workspace(vec![tab( + SessionPane::Leaf { + cwd: Some(PathBuf::from("/work")), + pane_id: Some(7), + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some("sid".into()), + agent_launch_argv: None, + }, + None, + )]); + ws.daemon_instance = Some("daemon-a".into()); + assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 1); + match &ws.session.tabs[0].pane { + SessionPane::Leaf { + pane_id, + agent_session_id, + .. + } => { + assert!(pane_id.is_none()); + assert_eq!(agent_session_id.as_deref(), Some("sid")); + } + SessionPane::Split { .. } => panic!("leaf stays a leaf"), + } + } + #[test] fn display_name_prefers_user_name_then_repo_then_cwd() { // No name, no repo group: fall back to the first leaf's directory. @@ -1199,6 +1402,71 @@ mod tests { ); } + /// The duplicate claim loses its agent resume along with its pane id. + /// Restore's spawn-fresh path auto-types the agent's resume command, and + /// the winning workspace's pane is still *running* that agent — a loser + /// that kept `agent_session_id` would come back as a second process on + /// the same conversation (double `claude --resume `, both live). + #[test] + fn dedupe_pane_ids_disarms_the_duplicate_claims_agent_resume() { + let agent_leaf = |pane_id| SessionPane::Leaf { + cwd: Some(PathBuf::from("/work")), + pane_id: Some(pane_id), + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some("362f9261".into()), + agent_launch_argv: Some(vec!["claude".into(), "--continue".into()]), + }; + let mut stale = workspace(vec![tab(agent_leaf(5), None)]); + stale.last_active = 100; + let mut fresh = workspace(vec![tab(agent_leaf(5), None)]); + fresh.last_active = 200; + let (stale_id, fresh_id) = (stale.id, fresh.id); + + let mut all = Workspaces { + active: Some(fresh_id), + workspaces: vec![stale, fresh], + }; + assert_eq!(all.dedupe_pane_ids(), 1); + + let loser = &all.get(stale_id).unwrap().session.tabs[0].pane; + match loser { + SessionPane::Leaf { + pane_id, + cwd, + agent_session_id, + agent_launch_argv, + .. + } => { + assert!(pane_id.is_none()); + assert_eq!( + cwd.as_deref(), + Some(std::path::Path::new("/work")), + "the layout survives — only the claim and its resume go" + ); + assert!( + agent_session_id.is_none(), + "no second resume of one conversation" + ); + assert!(agent_launch_argv.is_none()); + } + SessionPane::Split { .. } => panic!("the leaf must survive as a leaf"), + } + + // The winner is untouched: its pane is the one actually running the agent. + match &all.get(fresh_id).unwrap().session.tabs[0].pane { + SessionPane::Leaf { + pane_id, + agent_session_id, + .. + } => { + assert_eq!(*pane_id, Some(5)); + assert_eq!(agent_session_id.as_deref(), Some("362f9261")); + } + SessionPane::Split { .. } => panic!("the leaf must survive as a leaf"), + } + } + /// A pane id is only unique within one daemon, so the same number on two /// machines is not a collision. Deduping globally would make the remote /// workspace forfeit a claim on a pane that is alive on its own box — @@ -1383,7 +1651,7 @@ mod tests { assert_eq!(loaded.workspaces[0].host_id(), crate::host::HostId::LOCAL); } - /// The four key formats of contract §4.2, verbatim. These strings are a + /// The four key formats of the connection key, verbatim. These strings are a /// wire contract in all but name: change one and every workspace on that /// machine gets a different `HostId` than the connection pool minted. #[test] @@ -1540,7 +1808,7 @@ mod tests { assert_eq!(host.target.connection_key(), "ssh-direct:me@box.local:2222"); } - /// Every `Workspace` field belongs to exactly one side of design §10's + /// Every `Workspace` field belongs to exactly one side of the storage /// split. A new field that is in neither list would be silently dropped by /// `to_remote_json` and lost on the next pull, which is data loss that no /// other test would notice. @@ -1560,6 +1828,9 @@ mod tests { }, WorkspaceId::new(), )); + // Every skip-when-`None` field must be populated here, or it never + // serializes and this census can't see it. + ws.daemon_instance = Some("daemon-uuid".into()); let value = serde_json::to_value(&ws).unwrap(); let mut present: Vec = value diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs index 581023b2..d39be9db 100644 --- a/crates/tty7-core/src/core/shells.rs +++ b/crates/tty7-core/src/core/shells.rs @@ -26,10 +26,16 @@ use std::path::Path; #[cfg(windows)] use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + /// One launchable shell surfaced in the new-tab dropdown. `program` + `args` /// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a /// bare name resolved via `PATH` or an absolute path, plus launch arguments. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// Serializable because the dropdown of a **remote** workspace's window lists +/// the shells of the machine that workspace lives on, not this one's: the list +/// crosses the control dialect as [`ShellInventory`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DetectedShell { /// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`. pub label: String, @@ -47,6 +53,37 @@ impl DetectedShell { } } +/// What one machine can launch: its shells, plus which of them a plain new tab +/// lands on. The unit the new-tab dropdown is built from. +/// +/// Both halves have to come from the *same* machine. A remote workspace's +/// window that listed this computer's shells would offer `/bin/zsh` on a box +/// whose zsh is at `/usr/bin/zsh` — a picker whose every entry fails to spawn. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellInventory { + pub shells: Vec, + /// Short name of the shell a *default* spawn resolves to (`zsh`, + /// `PowerShell 7`), for the menu's `default` tag. + pub default_name: String, +} + +/// This machine's [`ShellInventory`], honoring the `shell` override in the +/// config file *this process* reads. +/// +/// The config lookup goes through [`crate::core::config::shell_command`] rather +/// than a GPUI global on purpose: the remote `tty7-server` answers this on the +/// far side of an SSH connection with no GUI in the process, and the override +/// that matters there is the one in *its* `config.json`. +/// +/// Runs filesystem probes — call off the UI thread. +pub fn inventory() -> ShellInventory { + let configured = crate::core::config::shell_command(); + ShellInventory { + shells: detect_shells(), + default_name: default_shell_name(configured.as_ref().map(|(p, _)| p.as_str())), + } +} + /// Enumerate the shells installed on this machine, best-effort. Order is /// meaningful: the entry most likely to be the user's default comes first. /// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread. diff --git a/crates/tty7-core/src/core/workspace_store.rs b/crates/tty7-core/src/core/workspace_store.rs index 6221695a..c4ad6f11 100644 --- a/crates/tty7-core/src/core/workspace_store.rs +++ b/crates/tty7-core/src/core/workspace_store.rs @@ -1,4 +1,4 @@ -//! The **remote** side of design §10's storage split: the machine's own +//! The **remote** side of the storage split: the machine's own //! `~/.local/share/tty7/workspaces.json`, and the one writer to it. //! //! # Which half of the split this is @@ -17,7 +17,7 @@ //! [`Workspace`](crate::core::session::Workspace). The server is a store, not a //! participant: the client owns the schema, and a client newer than the server //! it is talking to is the *normal* case (the server is installed once and then -//! left alone for months, §12's auto-install notwithstanding). Parsing here +//! left alone for months, auto-install notwithstanding). Parsing here //! would mean a field the server has never heard of is dropped on the next //! write — silent data loss whose only symptom is a setting that will not //! stick. @@ -96,7 +96,7 @@ const MAX_ID_BYTES: usize = 128; /// Who is currently attached to a workspace. /// -/// **Data only.** Design §10's takeover — push `Preempted { by }` to the old +/// **Data only.** The takeover — push `Preempted { by }` to the old /// session, close its streams, offer a [抢回] button — is M6's, and none of it /// is here. 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 @@ -643,7 +643,7 @@ fn quarantine(path: &Path) { /// |---|---|---| /// | 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 path in design §10 | +/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal fallback path | /// /// Deliberately **not** under the config dir. `session.json` there is the /// *client's* view state, and a box that is both someone's laptop and someone diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 2c8fc6b4..55e62a78 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -71,7 +71,7 @@ use std::collections::HashMap; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{RecvTimeoutError, SyncSender, sync_channel}; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -82,7 +82,44 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame}; /// independent of [`crate::daemon::protocol::PROTOCOL_VERSION`]: the pane /// protocol and the control dialect evolve on separate clocks, and a remote /// `tty7-server` speaks control without necessarily serving panes at all. -pub const CONTROL_VERSION: u32 = 1; +/// +/// Bump this when a new [`ControlRequest`] / [`ReplyOk`] variant lands. That is +/// stricter than the pane protocol's rule, and deliberately so: these enums have +/// no `#[serde(other)]` fallback either, but the consequence here is worse — +/// [`crate::daemon::install`] decides whether to *upgrade the remote binary* by +/// 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 +/// build knows the request and older ones don't" is what the number is for. +/// +/// ## History +/// +/// - **v2** — [`ControlRequest::Shells`], which backs a remote window's new-tab +/// dropdown. Not a `feature` string: every server from this build on answers +/// it, so the only thing a capability bit would have bought is that a machine +/// running an older server keeps running it forever, silently serving an empty +/// menu. The bump makes `RemoteProtocol::serves` refuse to adopt that server +/// and install this build's instead, which is the actual fix. +/// - **v1** — the dialect at the time remote workspaces landed. +pub const CONTROL_VERSION: u32 = 2; + +/// This process's identity as a control server, minted once on first use. +/// +/// Answers "am I still talking to the same server?" — the question no other +/// field in [`ControlHelloOk`] can answer, because `build` and both version +/// numbers survive a restart unchanged (the remote's binary is replaced in +/// place, keeping its name). A client that reconnects and sees a different value +/// here *knows* every `pane_id` it holds names a pane in a process that no +/// longer exists. +/// +/// Per **process**, not per connection: a server serves many connections and +/// they must all report the same instance, or the client would read every new +/// connection as a restart. +pub fn server_instance() -> &'static str { + static INSTANCE: OnceLock = OnceLock::new(); + INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string()) +} /// Paths coalesced into one [`ControlEvent::Watch`] window before the server /// gives up on precision and sends [`ControlEvent::WatchOverflow`] instead, @@ -168,6 +205,10 @@ pub mod feature { // mistranslation rather than a compile error. pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; +// Same rule for the machine's shell inventory: the dropdown's own type crosses +// the wire, not a wire-only copy of it. +pub use crate::core::shells::{DetectedShell, ShellInventory}; + // --------------------------------------------------------------------------- // Requests // --------------------------------------------------------------------------- @@ -267,6 +308,12 @@ pub enum ControlRequest { args: Vec, }, + // ----- machine inventory ------------------------------------------------- + /// The shells installed on the server, for the new-tab dropdown of a window + /// bound to it. Answered by every server speaking [`CONTROL_VERSION`] ≥ 2; + /// an older one is replaced rather than asked (see there). + Shells, + // ----- watch ------------------------------------------------------------ /// Open a subscription; the server answers with a [`ReplyOk::WatchId`]. WatchOpen { @@ -294,7 +341,7 @@ pub enum ControlRequest { id: String, }, - // ----- attachment (M6's takeover, design §10) --------------------------- + // ----- attachment (M6's takeover) --------------------------------------- /// Claim a workspace for this connection's session, taking it over from /// whoever held it. The server answers /// [`ReplyOk::Attached`] and pushes [`ControlEvent::Preempted`] to the @@ -327,7 +374,7 @@ impl ControlRequest { /// conservative timeout would make the fast paths feel broken; a single /// aggressive one would break the slow paths. /// - /// A timeout **never drops the connection** (§6.8): the request fails with + /// A timeout **never drops the connection**: the request fails with /// `TimedOut`, a [`kind::CANCEL`] goes out, and every other in-flight /// request is untouched. pub fn deadline(&self) -> Duration { @@ -347,6 +394,10 @@ impl ControlRequest { Duration::from_secs(10) } Git { .. } | Search { .. } => Duration::from_secs(20), + // Filesystem probes on Unix, but on Windows the WSL enumeration + // 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) } @@ -412,11 +463,13 @@ pub enum ReplyOk { Hits(Vec), Output(Output), 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 — design §10 only specifies the notice going the other way, + /// can say so — only the notice going the other way is specified, /// but a takeover the new client cannot see is one the user cannot explain. Attached { took_over_from: Option, @@ -551,7 +604,7 @@ pub enum ControlEvent { pane_id: u64, json: serde_json::Value, }, - /// Design §10's takeover: someone else attached to `workspace`, so this + /// The takeover: someone else attached to `workspace`, so this /// session no longer holds it. /// /// **`workspace` is not redundant.** One control connection carries a whole @@ -658,6 +711,23 @@ pub struct ControlHelloOk { /// Capability bits; see [`feature`]. #[serde(default)] pub features: Vec, + /// Which *process* answered — see [`server_instance`]. + /// + /// The one field here that changes without anything else changing. `build` + /// is the same across a restart, and so are both version numbers, so before + /// this a client that came back to a machine had no way to tell "the link + /// blinked" from "the server is a different process now and every pane it + /// held is gone". That question decides whether a reconnect re-attaches or + /// rebuilds, and guessing it wrong either throws away live shells or leaves + /// dead ones on screen. + /// + /// `#[serde(default)]` for the same reason every other added field has it, + /// though nothing can currently send an empty one: control v2 is the floor + /// and this landed with it. An empty value therefore means *unknown*, never + /// "a server that restarted" — a client that cannot tell must not act as if + /// it could. + #[serde(default)] + pub instance: String, } impl ControlHelloOk { @@ -1019,7 +1089,7 @@ pub type EventSink = Box; /// `shutdown`, a child process has `kill`, an SSH channel has `close` — hence /// this one-method abstraction rather than a bound on the stream type. /// -/// **Note for the server side** (contract §7.4's `Duplex`): the same problem +/// **Note for the server side** (the `Duplex`): the same problem /// exists there in mirror image, so `Duplex` will want the same capability. /// Aligning is a matter of `Duplex` either requiring `LinkShutdown` as a /// supertrait or exposing an equivalent method; this trait is deliberately @@ -1218,7 +1288,7 @@ impl ControlClient { /// Issue a request and block until its reply, `deadline` elapses, or the /// link drops. /// - /// Blocking is deliberate (contract §1): `Host` is a blocking, object-safe + /// Blocking is deliberate: `Host` is a blocking, object-safe /// trait, and every caller is already on a background thread. Blocking here /// blocks exactly one of them. pub fn call(&self, req: ControlRequest) -> io::Result { @@ -1566,6 +1636,7 @@ mod tests { separator: '/', home: "/home/me".into(), features: Vec::new(), + instance: "test-instance".into(), }, shutdown: None, reader_done: Mutex::new(false), @@ -1864,6 +1935,28 @@ mod tests { } } + /// One id for the whole process. A per-connection id would make every + /// reconnect look like a restart, which is the failure this whole mechanism + /// exists to avoid — in the *expensive* direction, since the client answers + /// a restart by rebuilding the window. + #[test] + fn the_server_instance_is_one_value_per_process() { + let first = server_instance(); + assert!(!first.is_empty(), "an empty instance means \"unknown\""); + assert_eq!(first, server_instance()); + } + + /// A client on an older v2 build decodes a hello that has no `instance` as + /// "unknown" rather than failing the whole handshake. + #[test] + fn a_hello_without_an_instance_still_decodes() { + let json = r#"{"control_version":2,"protocol_version":3,"build":"26.7.6", + "separator":"/","home":"/home/me"}"#; + let ok: ControlHelloOk = serde_json::from_str(json).expect("decodes without instance"); + assert_eq!(ok.instance, ""); + assert!(ok.features.is_empty()); + } + fn hello_ok() -> ControlHelloOk { ControlHelloOk { control_version: CONTROL_VERSION, @@ -1872,6 +1965,7 @@ mod tests { separator: '/', home: "/home/me".into(), features: vec![feature::CONTROL.into(), feature::HOST_RPC.into()], + instance: "test-instance".into(), } } diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs index 8828a95b..adaa30e3 100644 --- a/crates/tty7-core/src/daemon/install/asset.rs +++ b/crates/tty7-core/src/daemon/install/asset.rs @@ -2,11 +2,10 @@ //! release tag → download URL, and the remote paths a server binary lives at. //! //! Everything here is a total function of its arguments — no network, no SFTP, no -//! clock — which is the point: [`docs/remote-server-assets.md`] is a *literal* -//! contract with the release workflow, and a contract is only worth having if -//! both sides can be tested without standing up the other one. -//! -//! [`docs/remote-server-assets.md`]: ../../../../../docs/remote-server-assets.md +//! clock — which is the point: the asset naming here is a *literal* contract +//! with the release workflow (`.github/workflows/release.yml`), and a contract +//! is only worth having if both sides can be tested without standing up the +//! other one. use std::fmt; @@ -19,14 +18,13 @@ pub const CHECKSUMS_ASSET: &str = "checksums.txt"; /// Where release assets are downloaded from. The tag and asset name are appended /// (`{RELEASE_BASE}/{tag}/{asset}`); HTTPS to github.com is the trust anchor for -/// the checksum file itself (§16). +/// the checksum file itself. pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download"; /// The `XDG_DATA_HOME`-shaped directory tty7 owns on a remote machine, relative /// to `$HOME`. Split into components because the installer has to `mkdir` each /// level (SFTP has no `mkdir -p`) and because joining is `/`-only regardless of -/// the *client's* OS — a Windows client must not produce `.local\share` -/// (contract §4.3). +/// the *client's* OS — a Windows client must not produce `.local\share`. pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"]; /// Why a machine cannot be served a `tty7-server`. @@ -89,7 +87,7 @@ impl std::error::Error for UnsupportedTarget {} /// that dies with `Exec format error` at first exec — an error with no visible /// connection to the architecture detection that caused it, on a machine the user /// may not be able to inspect. An unknown machine string is a clean, explainable -/// refusal that names itself (`docs/remote-server-assets.md`). +/// refusal that names itself. /// /// `amd64` / `arm64` are accepted alongside the values Linux actually reports /// because some container images and BSD-flavoured userlands normalise to them. @@ -154,7 +152,7 @@ pub fn download_url(tag: &str, asset: &str) -> String { /// /// Built with explicit `/` joins from an absolute `$HOME` the remote resolved for /// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows -/// client — contract §4.3). +/// client). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePaths { /// `$HOME/.local/share/tty7/bin`. @@ -199,6 +197,26 @@ pub fn binary_name(version: &str) -> String { format!("tty7-server-{version}") } +/// [`RemotePaths`] pointing at a binary that is **already on the machine**, +/// found rather than named — the server a connect adopted because it speaks our +/// dialects (`Installer::adoptable_running_server`). +/// +/// `binary` is the path as the remote reported it, verbatim: it is what the +/// transport must connect to, and rebuilding it from a version parsed out of the +/// filename would turn a binary installed somewhere unexpected into a path that +/// does not exist. +/// +/// `temp` and `dir_chain` still describe *our* install location, because that is +/// where a later install would write. Nothing writes anything on the adoption +/// path, so they are unused there; keeping them well-formed means a caller that +/// falls back to installing does not need a second `RemotePaths`. +pub fn remote_paths_for_binary(home: &str, binary: &str) -> RemotePaths { + let version = version_from_path(binary); + let mut paths = remote_paths(home, version.as_deref().unwrap_or("unknown")); + paths.binary = binary.to_string(); + paths +} + /// The version encoded in an installed binary's *path*, if it is one of ours. /// /// This is how the running daemon's build is identified without asking it: the @@ -220,7 +238,7 @@ mod tests { use super::*; /// The contract's mapping table, row for row. This test *is* the client half - /// of `docs/remote-server-assets.md`: if the release workflow ever renames an + /// of the asset naming contract: if the release workflow ever renames an /// asset, this is where the two sides stop agreeing. #[test] fn uname_maps_to_the_published_assets() { diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs index 1699a55a..8a9960a9 100644 --- a/crates/tty7-core/src/daemon/install/checksums.rs +++ b/crates/tty7-core/src/daemon/install/checksums.rs @@ -1,4 +1,4 @@ -//! `checksums.txt` parsing and asset verification (§16). +//! `checksums.txt` parsing and asset verification. //! //! The release publishes one GNU coreutils `sha256sum`-format manifest covering //! every asset. HTTPS to github.com is the trust anchor — the manifest is not @@ -18,7 +18,7 @@ use sha2::{Digest as _, Sha256}; pub type Digest = [u8; 32]; /// Why an asset failed verification. Every variant aborts the install; none of -/// them is retried, and there is no unverified fallback (§17). +/// them is retried, and there is no unverified fallback. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChecksumError { /// The manifest has no line for this asset. Either the release is @@ -80,7 +80,7 @@ pub fn hex(digest: &Digest) -> String { }) } -/// Parse 64 hex characters into a digest. Case-insensitive (§16 step 3); any +/// Parse 64 hex characters into a digest. Case-insensitive; any /// other length or a non-hex character is a parse failure. fn parse_hex(s: &str) -> Option { if s.len() != 64 { @@ -95,7 +95,7 @@ fn parse_hex(s: &str) -> Option { /// The digest `manifest` records for `asset`. /// -/// **The filename field is matched whole, never by substring** (§16 step 2). +/// **The filename field is matched whole, never by substring.** /// `tty7-server-x86_64-unknown-linux-musl` happens not to be a substring of any /// other asset today, but that is an accident of the current release contents, /// not a property anyone maintains — and a substring match that drifted would @@ -165,7 +165,7 @@ mod tests { verify(&manifest, ASSET_X86_64, bytes).expect("the published bytes must verify"); } - /// Uppercase hex in the manifest is still the same digest (§16 step 3). + /// Uppercase hex in the manifest is still the same digest. #[test] fn digest_comparison_is_case_insensitive() { let bytes = b"payload".as_slice(); @@ -174,7 +174,7 @@ mod tests { verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter"); } - /// **The failure path §18 names.** Bytes that do not match must abort with + /// **The failure path.** Bytes that do not match must abort with /// both digests reported — not retry, not install anyway. #[test] fn mismatched_bytes_abort_with_both_digests() { @@ -255,8 +255,8 @@ mod tests { } /// **Whole-field match, not substring.** A manifest carrying a longer name - /// that *contains* ours must not satisfy the lookup — this is the guard §16 - /// step 2 asks for. + /// that *contains* ours must not satisfy the lookup — this is the guard + /// whole-field matching exists for. #[test] fn filename_matching_is_exact_not_substring() { let payload = b"decoy".as_slice(); diff --git a/crates/tty7-core/src/daemon/install/download.rs b/crates/tty7-core/src/daemon/install/download.rs index b4825da0..1a83c1ab 100644 --- a/crates/tty7-core/src/daemon/install/download.rs +++ b/crates/tty7-core/src/daemon/install/download.rs @@ -33,6 +33,12 @@ const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180); /// buffering it in memory before finding out is not a good trade. const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024; +/// How much body to take per read, and therefore how often progress is +/// reported: ~130 updates over a 8 MB asset. Large enough that the syscall +/// overhead stays irrelevant, small enough that a bar moves smoothly rather +/// than in visible jumps. +const READ_CHUNK: usize = 64 * 1024; + /// Downloads release assets over HTTPS. pub struct HttpsFetcher { agent: ureq::Agent, @@ -52,6 +58,14 @@ impl Default for HttpsFetcher { impl AssetFetcher for HttpsFetcher { fn get(&self, url: &str) -> Result, String> { + self.get_with_progress(url, &|_, _| {}) + } + + fn get_with_progress( + &self, + url: &str, + on_progress: &dyn Fn(u64, Option), + ) -> Result, String> { let response = self .agent .get(url) @@ -73,16 +87,36 @@ impl AssetFetcher for HttpsFetcher { return Err(format!("{url} returned HTTP {status}")); } + // Only a hint: it is what the *server* claims, so it sizes the + // allocation and the progress bar but never the ceiling check below. + let declared = response + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n <= MAX_ASSET_BYTES); + let mut body = response.into_body(); - let mut bytes = Vec::new(); - body.as_reader() - .take(MAX_ASSET_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|e| describe(url, &e.to_string()))?; - if bytes.len() as u64 > MAX_ASSET_BYTES { - return Err(format!( - "{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset" - )); + // `take` still caps the read, so a lying (or absent) Content-Length + // cannot make this buffer more than the ceiling — one byte over is + // enough to detect it, which is why the limit is `+ 1`. + let mut reader = body.as_reader().take(MAX_ASSET_BYTES + 1); + let mut bytes = Vec::with_capacity(declared.unwrap_or(0) as usize); + let mut buf = vec![0u8; READ_CHUNK]; + loop { + let n = reader + .read(&mut buf) + .map_err(|e| describe(url, &e.to_string()))?; + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + if bytes.len() as u64 > MAX_ASSET_BYTES { + return Err(format!( + "{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset" + )); + } + on_progress(bytes.len() as u64, declared); } Ok(bytes) } @@ -130,7 +164,7 @@ mod tests { /// of nothing. Asserting real content is what catches that. /// - **The TLS trust anchor works.** ureq's webpki roots must accept /// github.com's chain; that HTTPS connection *is* the security model here - /// (§16 — `checksums.txt` is not separately signed). + /// (`checksums.txt` is not separately signed). /// /// Deliberately a small file rather than a release asset: assets are ~20 MB /// and this is a correctness check, not a bandwidth test. diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index fb897797..c7c30954 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -1,5 +1,4 @@ -//! Installing, launching and version-matching `tty7-server` on a remote machine -//! (design §12, §16, §17). +//! Installing, launching and version-matching `tty7-server` on a remote machine. //! //! The six steps, in order: //! @@ -23,7 +22,7 @@ //! //! ## …except for WSL, which downloads nothing //! -//! Design §12's last paragraph: a WSL distro is served the Linux binary the +//! A WSL distro is served the Linux binary the //! *Windows client already shipped with*, not one fetched from a release. Both //! paths meet at [`ServerBinarySource`] — [`ReleaseDownload`] for a real remote, //! [`wsl::BundledServerBinary`] for a distro on this machine — so steps 2 and @@ -40,7 +39,7 @@ //! //! ## Scope //! -//! Nothing here uses `sudo` or writes outside `$HOME` (§16). Nothing here opens +//! Nothing here uses `sudo` or writes outside `$HOME`. Nothing here opens //! the workspace link either: this module's contract with the transport //! (`remote_link` / the SSH router) is exactly [`ensure_remote_server`] — call it, //! and on `Ok` the far end has the right binary installed and a daemon serving. @@ -72,7 +71,7 @@ pub fn client_version() -> &'static str { /// 0700 — the *directory* is 0700, which is what actually scopes access, and a /// 0755 binary matches what every other user-local install looks like. const BINARY_MODE: u32 = 0o755; -/// Mode bits for every directory we create (§16: directories 0700). +/// Mode bits for every directory we create (directories 0700). const DIR_MODE: u32 = 0o700; /// How long a freshly launched remote daemon gets to start answering on its @@ -154,6 +153,22 @@ pub trait RemoteOps: Send + Sync { fn chmod(&self, path: &str, mode: u32) -> Result<(), String>; /// Write `bytes` to `path`, truncating anything already there. fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String>; + /// [`put`](Self::put), calling `on_progress(written)` as the write + /// advances. Defaulted to plain `put` for the same reason as + /// [`AssetFetcher::get_with_progress`]: an in-memory fake writes all of it + /// at once and has no intermediate state to report. + fn put_with_progress( + &self, + path: &str, + bytes: &[u8], + on_progress: &(dyn Fn(u64) + Send + Sync), + ) -> Result<(), String> { + let result = self.put(path, bytes); + if result.is_ok() { + on_progress(bytes.len() as u64); + } + result + } /// Rename `from` over `to`. Same directory, so same filesystem, so atomic. fn rename(&self, from: &str, to: &str) -> Result<(), String>; fn remove_file(&self, path: &str) -> Result<(), String>; @@ -167,6 +182,22 @@ pub trait RemoteOps: Send + Sync { /// feature). pub trait AssetFetcher: Send + Sync { fn get(&self, url: &str) -> Result, String>; + + /// [`get`](Self::get), calling `on_progress(done, total)` as the body + /// arrives. `total` is the `Content-Length` when the server sent one. + /// + /// Defaulted to plain `get` so a fetcher that has nothing useful to say + /// mid-transfer — every fake in the tests, and the checksums fetch, which is + /// under a kilobyte — implements one method, not two. The real + /// [`HttpsFetcher`](download::HttpsFetcher) overrides it. + fn get_with_progress( + &self, + url: &str, + on_progress: &dyn Fn(u64, Option), + ) -> Result, String> { + let _ = on_progress; + self.get(url) + } } /// A verified server binary, and where it came from. @@ -206,6 +237,21 @@ impl std::fmt::Debug for LoadedBinary { /// ourselves would verify nothing that the client's own signature did not. pub trait ServerBinarySource: Send + Sync { fn load(&self, version: &str, asset: &'static str) -> Result; + + /// [`load`](Self::load), reporting bytes as they arrive. + /// + /// Defaulted to plain `load` because only one of the three sources has a + /// transfer worth watching: [`wsl::BundledServerBinary`] reads a local file + /// and is done before a bar could paint. + fn load_with_progress( + &self, + version: &str, + asset: &'static str, + on_progress: &dyn Fn(u64, Option), + ) -> Result { + let _ = on_progress; + self.load(version, asset) + } } /// A local binary if [`wsl::BUNDLED_DIR_ENV`] names a directory holding one, @@ -243,28 +289,53 @@ impl<'a> BundledOrRelease<'a> { impl ServerBinarySource for BundledOrRelease<'_> { fn load(&self, version: &str, asset: &'static str) -> Result { + self.load_with_progress(version, asset, &|_, _| {}) + } + + fn load_with_progress( + &self, + version: &str, + asset: &'static str, + on_progress: &dyn Fn(u64, Option), + ) -> Result { match &self.bundled { // A named directory that does *not* hold this asset is an error, not // a reason to fall back: someone who set the variable meant to // install from it, and quietly downloading instead would defeat // whichever of the reasons above they set it for. Some(bundled) => bundled.load(version, asset), - None => ReleaseDownload { fetch: self.fetch }.load(version, asset), + None => ReleaseDownload { fetch: self.fetch }.load_with_progress( + version, + asset, + on_progress, + ), } } } /// The default source: fetch the release asset and its `checksums.txt` over /// HTTPS, and verify one against the other before anything is written or the -/// user is asked (§16, §17). +/// user is asked. pub struct ReleaseDownload<'a> { pub fetch: &'a dyn AssetFetcher, } impl ServerBinarySource for ReleaseDownload<'_> { fn load(&self, version: &str, asset: &'static str) -> Result { + self.load_with_progress(version, asset, &|_, _| {}) + } + + fn load_with_progress( + &self, + version: &str, + asset: &'static str, + on_progress: &dyn Fn(u64, Option), + ) -> Result { let tag = asset::release_tag(version); let manifest_url = asset::download_url(&tag, asset::CHECKSUMS_ASSET); + // Not reported: `checksums.txt` is under a kilobyte, and a bar that + // jumped to 100% for it before restarting for the real asset would read + // as a stall rather than as two files. let manifest = self .fetch .get(&manifest_url) @@ -280,7 +351,7 @@ impl ServerBinarySource for ReleaseDownload<'_> { let asset_url = asset::download_url(&tag, asset); let bytes = self .fetch - .get(&asset_url) + .get_with_progress(&asset_url, on_progress) .map_err(|reason| InstallError::Download { url: asset_url.clone(), reason, @@ -295,7 +366,7 @@ impl ServerBinarySource for ReleaseDownload<'_> { } // --------------------------------------------------------------------------- -// Consent (§12, §16) — the decision point M5's UI plugs into. +// Consent — the decision point M5's UI plugs into. // --------------------------------------------------------------------------- /// Everything the user needs to answer "may tty7 write a binary onto this @@ -332,7 +403,7 @@ pub enum InstallDecision { } /// Asks the user whether to write a server binary onto a machine for the first -/// time (§12: "往别人机器上写二进制值得问一次"). +/// time ("往别人机器上写二进制值得问一次"). /// /// **Only the first install on a given machine asks.** "First" is decided from /// evidence on the remote itself — an empty (or absent) @@ -418,7 +489,219 @@ pub fn install_confirm() -> Arc { } // --------------------------------------------------------------------------- -// Version negotiation (§12, mirroring `spawn::ensure_running`). +// Install progress +// --------------------------------------------------------------------------- + +/// How far a first install has got, in bytes. +/// +/// Only the two steps that take real time appear. `uname`, `stat`, `mkdir`, +/// `chmod` and the rename are single round trips: a phase for each would flicker +/// past faster than it could be read, and a progress display that spends most of +/// its life on two steps is better off saying which of the two it is on. +/// +/// The byte counts are of the *asset*, so `Uploading` restarts at zero rather +/// than continuing where `Downloading` left off. Two bars' worth of work shown +/// as one 0-200% sweep would be worse; two named phases each running 0-100% is +/// what the user is actually waiting through. +/// +/// Serialisable because the install runs in the **daemon** and the user is in +/// the GUI: this crosses the routed connection as a +/// [`RoutePrompt::InstallProgress`](crate::daemon::router::RoutePrompt) frame. +/// Unlike [`InstallRequest`] it needs no wire twin — every field is already a +/// plain number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallPhase { + /// Fetching the asset onto *this* machine over HTTPS. + /// + /// `total` is `None` when the server sent no `Content-Length` — rare for a + /// release asset, but a chunked response is legal and a progress sink that + /// cannot represent "unknown total" would have to invent one. + Downloading { done: u64, total: Option }, + /// Writing the verified bytes to the remote over SFTP. `total` is exact: + /// the bytes are in memory by now. + Uploading { done: u64, total: u64 }, +} + +impl InstallPhase { + /// Fraction complete in `0.0..=1.0`, or `None` when the total is unknown. + pub fn fraction(&self) -> Option { + let (done, total) = match *self { + InstallPhase::Downloading { done, total } => (done, total?), + InstallPhase::Uploading { done, total } => (done, total), + }; + if total == 0 { + return None; + } + Some((done as f32 / total as f32).clamp(0.0, 1.0)) + } +} + +/// Watches an install go by (the first install writes ~8 MB across +/// two network hops, and a client that says only "connecting…" for the length of +/// both is indistinguishable from one that has hung). +/// +/// **Reports are frequent and must be cheap.** One arrives per transfer chunk — +/// hundreds over a single install — so an implementation stores the latest and +/// returns. It must not block, lock anything a UI thread holds, or do IO: the +/// thread calling this is the one moving the bytes. +/// +/// **Nothing here affects the install.** It is a side channel, which is why +/// [`Installer`] reaches for it through the global rather than carrying it as a +/// field the way it carries [`InstallConfirm`] — a sink cannot change what gets +/// written, so it does not belong in the constructor every caller and fake has +/// to satisfy. +/// +/// The default ([`SilentProgress`]) drops everything, which is the right +/// behaviour for a headless daemon with nobody watching. +pub trait InstallProgress: Send + Sync { + /// `host` is the same label [`InstallRequest::host`] carries, so a sink + /// serving several machines can tell them apart. + fn report(&self, host: &str, phase: InstallPhase); +} + +/// The default: nobody is watching, so nothing is recorded. +pub struct SilentProgress; + +impl InstallProgress for SilentProgress { + fn report(&self, _host: &str, _phase: InstallPhase) {} +} + +static PROGRESS: OnceLock>> = OnceLock::new(); + +fn progress_slot() -> &'static Mutex> { + PROGRESS.get_or_init(|| Mutex::new(Arc::new(SilentProgress))) +} + +/// Register the process-wide progress sink. Called once by the GUI at startup; +/// last call wins. +pub fn set_install_progress(progress: Arc) { + if let Ok(mut slot) = progress_slot().lock() { + *slot = progress; + } +} + +thread_local! { + /// A sink that outranks [`PROGRESS`] for the duration of one call, on one + /// thread. See [`with_install_progress`]. + static SCOPED_PROGRESS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Run `f` with `progress` receiving any install it drives, then put the +/// previous sink back. +/// +/// The same shape, and the same reason, as [`with_install_confirm`]: in the +/// daemon the only sink that can reach a user is one bound to a particular +/// routed connection, and two machines installing at once through a global would +/// report both machines' bytes to whichever client asked last. +pub fn with_install_progress(progress: Arc, f: impl FnOnce() -> T) -> T { + let previous = SCOPED_PROGRESS.with(|slot| slot.borrow_mut().replace(progress)); + let out = f(); + SCOPED_PROGRESS.with(|slot| *slot.borrow_mut() = previous); + out +} + +/// The progress sink in force: this thread's scoped one, else the process-wide +/// one, else [`SilentProgress`]. +pub fn install_progress() -> Arc { + if let Some(scoped) = SCOPED_PROGRESS.with(|slot| slot.borrow().clone()) { + return scoped; + } + progress_slot() + .lock() + .map(|slot| slot.clone()) + .unwrap_or_else(|_| Arc::new(SilentProgress)) +} + +// --------------------------------------------------------------------------- +// Asking a server binary what it speaks +// --------------------------------------------------------------------------- + +/// The flag that makes a `tty7-server` print [`RemoteProtocol`] and exit. +/// +/// A *file*, not a running daemon: the numbers are compile-time constants, so +/// this answers "what would this binary speak" without a socket, a handshake, or +/// anything already being up. That is what lets the installer decide whether to +/// write 8 MB **before** writing it. +/// +/// Servers older than this flag print usage to stderr and exit non-zero, which +/// [`Installer::probe_protocol`] reads as "no opinion" — the same conservative +/// answer an unreadable `/proc` gets. +pub const PROTOCOL_FLAG: &str = "--protocol"; + +/// What a `tty7-server` binary speaks, as it reports itself. +/// +/// The remote counterpart of [`DaemonVersion`](crate::daemon::protocol::DaemonVersion), +/// and deliberately the same shape: two dialect numbers that decide +/// compatibility, plus a build string that decides nothing. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RemoteProtocol { + /// [`crate::daemon::control::CONTROL_VERSION`] — the control dialect, which + /// is what a remote *workspace* runs on. + pub control: u32, + /// [`crate::daemon::protocol::PROTOCOL_VERSION`] — the pane dialect, which + /// is what a routed *pane* on that machine runs on. + pub protocol: u32, + /// `CARGO_PKG_VERSION`. **Display only** — same rule as + /// [`DaemonVersion::build`](crate::daemon::protocol::DaemonVersion::build) + /// and [`ControlHelloOk::build`](crate::daemon::control::ControlHelloOk::build). + /// Two builds that speak the same numbers are interchangeable no matter what + /// their version strings say, and treating a version string as a dialect is + /// exactly the bug this type exists to end. + pub build: String, +} + +impl RemoteProtocol { + /// What this client speaks. + pub fn of_this_build() -> RemoteProtocol { + RemoteProtocol { + control: crate::daemon::control::CONTROL_VERSION, + protocol: crate::daemon::protocol::PROTOCOL_VERSION, + build: client_version().to_string(), + } + } + + /// Whether a server speaking `self` can serve a client speaking `other`. + /// + /// **Both numbers, both exactly equal** — the same judgement + /// `spawn::ensure_running` makes locally (`v.protocol == PROTOCOL_VERSION`), + /// applied to both dialects because a remote workspace uses both: control + /// for the workspace itself, pane for every terminal in it. + /// + /// Equality rather than `>=` deliberately. A newer server is not + /// automatically able to speak an older client's dialect, and guessing that + /// it can turns a clean prompt into a wire error halfway through a session. + pub fn serves(&self, other: &RemoteProtocol) -> bool { + self.control == other.control && self.protocol == other.protocol + } + + /// The single line a server prints for [`PROTOCOL_FLAG`]. + /// + /// Paired with [`parse`](Self::parse) here rather than left to each side's + /// own `serde_json` call: the writer is `tty7-server` and the reader is the + /// client, they ship separately and meet over SSH, and one shared function + /// is what stops the format drifting between them. + pub fn to_line(&self) -> String { + // Infallible in practice — three plain fields — and a server that could + // not describe itself should still exit cleanly rather than make the + // caller handle an error that cannot happen. + serde_json::to_string(self).unwrap_or_default() + } + + /// Parse one from a probe's stdout. + /// + /// Takes the **last** non-blank line: a login shell that prints a banner + /// from `.bashrc` would otherwise poison an otherwise fine answer, and the + /// server writes its line last because it writes it at exit. + pub fn parse(stdout: &str) -> Option { + let line = stdout.lines().rev().find(|l| !l.trim().is_empty())?; + serde_json::from_str(line.trim()).ok() + } +} + +// --------------------------------------------------------------------------- +// Version negotiation (mirroring `spawn::ensure_running`). // --------------------------------------------------------------------------- /// A remote daemon that is serving a machine at a *different* build than the @@ -498,7 +781,7 @@ fn record_mismatch(entry: MismatchedRemoteDaemon) { /// The relay's landing point (`daemon::router`): the daemon finds the mismatch, /// the GUI is the process with the keep-or-restart prompt, and /// [`take_mismatched_remote_daemons`] only ever reads a local static. Without -/// this the prompt design §12 specifies could not fire at all. +/// this the consent prompt could not fire at all. pub fn record_remote_mismatches(entries: Vec) { for entry in entries { record_mismatch(entry); @@ -516,7 +799,7 @@ pub fn take_mismatched_remote_daemons() -> Vec { } // --------------------------------------------------------------------------- -// Errors (§17: specific, path-bearing, never retried into a different path). +// Errors (specific, path-bearing, never retried into a different path). // --------------------------------------------------------------------------- #[derive(Debug)] @@ -531,12 +814,11 @@ pub enum InstallError { /// proxy). Carries the URL, because "which release did it even look for" is /// the first question. Download { url: String, reason: String }, - /// sha256 verification failed. Terminal: no retry, no unverified fallback - /// (§16, §17). + /// sha256 verification failed. Terminal: no retry, no unverified fallback. Checksum(ChecksumError), /// A WSL install found no bundled Linux server binary in this client's own /// installation. Terminal, and deliberately **not** downgraded to a - /// download: design §12 says a WSL distro is served the binary the client + /// download: a WSL distro is served the binary the client /// shipped with, and silently reaching for GitHub instead would turn a /// packaging bug into an intermittent network failure on someone else's /// machine. Names every directory that was looked in, because the fix is @@ -549,7 +831,7 @@ pub enum InstallError { Declined { host: String, path: String }, /// A write to the remote failed — full disk, read-only home, no permission. /// Reports the exact path and the server's own reason, and is **not** - /// retried anywhere else (§17: "不重试,不降级到别的路径"). + /// retried anywhere else ("不重试,不降级到别的路径"). Write { path: String, reason: String }, /// The daemon would not start, or would not answer after starting. Launch { reason: String }, @@ -623,8 +905,19 @@ pub struct InstallReport { pub confirmed: bool, /// Whether a daemon had to be launched (false when one was already serving). pub launched: bool, - /// Set when a daemon of another build is serving this machine. + /// Set when a daemon that **cannot serve this client** is on the machine. + /// + /// A different build is not a mismatch — a different *dialect* is. See + /// [`Installer::check_running_build`]. pub mismatch: Option, + /// The already-running server this connect adopted instead of installing, + /// when its dialects matched ours despite a different build. + /// + /// `Some` is the case always intended and the implementation + /// missed: a 26.7.6 client meeting a 26.7.7 server they both speak. Recorded + /// because "we deliberately did not install" is otherwise indistinguishable + /// in a log from "we forgot to". + pub reused: Option, } // --------------------------------------------------------------------------- @@ -739,6 +1032,7 @@ impl<'a> Installer<'a> { confirmed: false, launched: false, mismatch: None, + reused: None, }; // A file that exists but is not executable is a half-finished install @@ -746,18 +1040,84 @@ impl<'a> Installer<'a> { // than launching something the kernel will refuse. let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0); if !usable { - let (confirmed, _) = self.install(asset, &paths)?; - report.installed = true; - report.confirmed = confirmed; + // --- 3. before writing 8 MB, ask what is already serving ---------- + // + // Version skew is settled by comparing dialects, not + // build strings. Only the *running* server is asked: the socket is + // singular, so a compatible binary that is merely present on disk + // would still have to be started — and starting our own is simpler + // and more predictable than adopting a stranger's file. + match self.adoptable_running_server()? { + Some((exe, spoken)) => { + log::info!( + "remote {}: adopting the running {} (control {}, protocol {}) \ + instead of installing {} — same dialects", + self.host, + spoken.build, + spoken.control, + spoken.protocol, + self.version, + ); + report.paths = asset::remote_paths_for_binary(&home, &exe); + report.reused = Some(spoken); + } + None => { + let (confirmed, _) = self.install(asset, &paths)?; + report.installed = true; + report.confirmed = confirmed; + } + } } // --- 6. make sure a daemon is serving -------------------------------- - let (launched, mismatch) = self.ensure_daemon(&paths)?; + let (launched, mismatch) = self.ensure_daemon(&report.paths)?; report.launched = launched; report.mismatch = mismatch; Ok(report) } + /// The running `tty7-server` on this machine, when it speaks our dialects. + /// + /// `None` covers every reason not to adopt one, and they are deliberately + /// indistinguishable to the caller: nothing running, an unreadable `/proc`, + /// a binary too old to know [`PROTOCOL_FLAG`], or one that answered with + /// dialects we cannot speak. All four mean "install ours", and none of them + /// is an error — a machine we cannot interrogate is a machine we install on, + /// exactly as before this existed. + fn adoptable_running_server(&self) -> Result, InstallError> { + let Some(exe) = self.running_server_exe() else { + return Ok(None); + }; + let Some(spoken) = self.probe_protocol(&exe) else { + return Ok(None); + }; + if !spoken.serves(&RemoteProtocol::of_this_build()) { + return Ok(None); + } + Ok(Some((exe, spoken))) + } + + /// Ask a server *binary* what it speaks. `None` if it cannot say. + /// + /// Cheap by design: one SSH command against a file, no socket and no daemon, + /// so it can be asked before deciding whether to transfer anything. + fn probe_protocol(&self, exe: &str) -> Option { + let cmd = format!("{} {PROTOCOL_FLAG}", shell_quote(exe)); + let out = self.ops.run(&cmd).ok()?; + if !out.success() { + // A server older than the flag prints usage and exits non-zero. + return None; + } + RemoteProtocol::parse(&out.stdout) + } + + /// The executable path of this user's running `tty7-server`, if any. + fn running_server_exe(&self) -> Option { + let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?; + let exe = out.stdout.trim(); + (!exe.is_empty()).then(|| exe.to_string()) + } + /// Steps 3–5: download, verify, confirm, upload, publish. fn install( &self, @@ -808,8 +1168,12 @@ impl<'a> Installer<'a> { // refuses SETSTAT) must not block an install that will otherwise work. let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE); + let sink = install_progress(); + let total = bytes.len() as u64; self.ops - .put(&paths.temp, &bytes) + .put_with_progress(&paths.temp, &bytes, &|done| { + sink.report(&self.host, InstallPhase::Uploading { done, total }); + }) .map_err(|reason| InstallError::Write { path: paths.temp.clone(), reason, @@ -847,8 +1211,12 @@ impl<'a> Installer<'a> { /// Where step 3's bytes come from: the injected source if there is one, /// otherwise a [`ReleaseDownload`] over the injected fetcher. fn load_binary(&self, asset: &'static str) -> Result { + let sink = install_progress(); + let on_progress = |done: u64, total: Option| { + sink.report(&self.host, InstallPhase::Downloading { done, total }); + }; if let Some(source) = self.source { - return source.load(&self.version, asset); + return source.load_with_progress(&self.version, asset, &on_progress); } let Some(fetch) = self.fetch else { // Unreachable through either constructor; a plain error rather than @@ -859,7 +1227,7 @@ impl<'a> Installer<'a> { reason: "no binary source was configured".to_string(), }); }; - ReleaseDownload { fetch }.load(&self.version, asset) + ReleaseDownload { fetch }.load_with_progress(&self.version, asset, &on_progress) } /// Whether tty7 has ever written to this machine, decided from the remote's @@ -884,7 +1252,7 @@ impl<'a> Installer<'a> { /// so its exit status *is* the answer, and a socket file a crash left behind /// reads as "nothing there" rather than as a live server. Nothing here /// parses a frame — the protocol handshake is end-to-end between the GUI and - /// the far server (contract §6.9), and a second opinion about the version + /// the far server, and a second opinion about the version /// living down here is exactly the coupling that design forbids. fn ensure_daemon( &self, @@ -930,24 +1298,41 @@ impl<'a> Installer<'a> { .map_err(|reason| InstallError::Launch { reason }) } - /// Identify the build of the daemon that is actually serving, and record a - /// mismatch if it is not ours. + /// Identify the daemon that is actually serving, and record a mismatch only + /// if it **cannot speak to us**. /// - /// The install path carries the version by construction, so reading the - /// running process's executable link answers this for *every* build we have - /// shipped — including ones older than any handshake we could send them. - /// Failing to read it is not an error: an unreadable `/proc` means we simply - /// have no opinion, and no opinion must never be reported as a mismatch. + /// **A different build is not a mismatch.** The rule is to compare + /// `PROTOCOL_VERSION` and keep an older server that is compatible — the same judgement + /// `spawn::ensure_running` makes locally, where a daemon whose `build` + /// differs but whose `protocol` matches is reused in silence. Comparing + /// version *strings* here is what made a 26.7.6 client prompt about a + /// 26.7.7 server it could talk to perfectly well, and made it upload 8 MB to + /// a machine that needed nothing. + /// + /// Failing to read any of it is not an error: an unreadable `/proc`, or a + /// server too old to know [`PROTOCOL_FLAG`], means we have no opinion — and + /// no opinion must never be reported as a mismatch. fn check_running_build(&self, paths: &RemotePaths) -> Option { - let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?; - let exe = out.stdout.trim(); - if exe.is_empty() { - return None; - } + let exe = self.running_server_exe()?; + let exe = exe.as_str(); let running_version = asset::version_from_path(exe); if running_version.as_deref() == Some(self.version.as_str()) || exe == paths.binary { return None; } + // A different build, so ask the only question that decides anything. + // An unanswerable probe leaves the old behaviour in place: a server that + // predates the flag really might not understand us, and the prompt is + // the honest response to not knowing. + if self + .probe_protocol(exe) + .is_some_and(|spoken| spoken.serves(&RemoteProtocol::of_this_build())) + { + log::info!( + "remote {} is served by {exe}, a different build this client speaks to anyway", + self.host, + ); + return None; + } let entry = MismatchedRemoteDaemon { host: self.host.clone(), running_version, @@ -1007,7 +1392,7 @@ impl<'a> Installer<'a> { /// Find the executable path of this user's running `tty7-server`, if any. /// /// `readlink /proc//exe` is readable only for the caller's own processes, -/// which is exactly the scope wanted: one `tty7-server` per user (contract §8). +/// which is exactly the scope wanted: one `tty7-server` per user. /// `|| true` on the loop keeps a `set -e` login shell from turning "no daemon /// running" into a failed command. const RUNNING_EXE_COMMAND: &str = r#"for p in /proc/[0-9]*; do e=$(readlink "$p/exe" 2>/dev/null) || continue; case "$e" in */tty7-server-*) printf '%s' "${e% (deleted)}"; break;; esac; done; true"#; diff --git a/crates/tty7-core/src/daemon/install/ssh_ops.rs b/crates/tty7-core/src/daemon/install/ssh_ops.rs index 5ea0adb4..e353fc75 100644 --- a/crates/tty7-core/src/daemon/install/ssh_ops.rs +++ b/crates/tty7-core/src/daemon/install/ssh_ops.rs @@ -155,7 +155,16 @@ impl RemoteOps for SshRemoteOps { } fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> { - SftpManager::global().put_bytes(&self.conn, path, bytes) + SftpManager::global().put_bytes(&self.conn, path, bytes, &|_| {}) + } + + fn put_with_progress( + &self, + path: &str, + bytes: &[u8], + on_progress: &(dyn Fn(u64) + Send + Sync), + ) -> Result<(), String> { + SftpManager::global().put_bytes(&self.conn, path, bytes, on_progress) } fn rename(&self, from: &str, to: &str) -> Result<(), String> { @@ -255,7 +264,7 @@ mod tests { } } - /// And must not swallow the failures §17 requires to be reported: a full + /// And must not swallow the failures that have to be reported: a full /// disk or a read-only home has to surface as an error with a path, never as /// "the file isn't there, go ahead and install". #[test] diff --git a/crates/tty7-core/src/daemon/install/tests.rs b/crates/tty7-core/src/daemon/install/tests.rs index d9568546..43f224b3 100644 --- a/crates/tty7-core/src/daemon/install/tests.rs +++ b/crates/tty7-core/src/daemon/install/tests.rs @@ -1,6 +1,6 @@ //! The install flow, driven end to end against an in-memory remote. //! -//! Contract §18 asks for four things by name — `uname` parsing, version path +//! Four things are asked for by name — `uname` parsing, version path //! construction, atomic replacement, and the sha256 failure path — and none of //! them may touch the network. The first two are unit-tested in //! [`super::asset`] and [`super::checksums`]; the last two need the *whole* @@ -64,6 +64,10 @@ struct FakeRemote { /// Whether launching actually starts the fake daemon (false models a binary /// that dies on exec). launch_works: bool, + /// What each binary answers to `--protocol`, by path. A path that is absent + /// models a server too old to know the flag: the probe fails, and the + /// installer falls back to having no opinion. + speaks: Mutex>, } impl FakeRemote { @@ -85,9 +89,16 @@ impl FakeRemote { daemon_running: Mutex::new(false), running_exe: Mutex::new(None), launch_works: true, + speaks: Mutex::new(HashMap::new()), } } + /// Teach the binary at `exe` to answer `--protocol` with `spoken`. + fn speaking(self, exe: &str, spoken: RemoteProtocol) -> Self { + self.speaks.lock().unwrap().insert(exe.to_string(), spoken); + self + } + /// A machine tty7 has installed on before (so consent is not re-asked). fn with_previous_install(self, version: &str) -> Self { self.preinstall(&format!("{BIN_DIR}/tty7-server-{version}"), 0o755); @@ -152,6 +163,19 @@ impl RemoteOps for FakeRemote { if cmd == "uname -sm" { return ok(&self.uname); } + if let Some(exe) = cmd.strip_suffix(&format!(" {PROTOCOL_FLAG}")) { + let exe = exe.trim_matches('\''); + return match self.speaks.lock().unwrap().get(exe) { + Some(spoken) => ok(&serde_json::to_string(spoken).unwrap()), + // What a server older than the flag does: usage on stderr, and + // a non-zero status. + None => Ok(ExecOutput { + status: Some(1), + stdout: String::new(), + stderr: "tty7-server: nothing to do without --daemon or --stdio".into(), + }), + }; + } if cmd == RUNNING_EXE_COMMAND { let exe = self.running_exe.lock().unwrap().clone().unwrap_or_default(); return ok(&exe); @@ -485,7 +509,7 @@ fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() { } /// The directory chain is created outermost-first (SFTP has no `mkdir -p`) and -/// the directory that holds the binaries ends up 0700 (§16). +/// the directory that holds the binaries ends up 0700. #[test] fn the_install_directory_is_created_in_order_and_locked_down() { let remote = FakeRemote::new(); @@ -516,7 +540,7 @@ fn the_install_directory_is_created_in_order_and_locked_down() { } // --------------------------------------------------------------------------- -// sha256 (§16, §17) — the failure path §18 names. +// sha256 — the failure path. // --------------------------------------------------------------------------- /// **A checksum mismatch aborts and writes nothing.** Not a retry, not an @@ -578,10 +602,10 @@ fn a_release_missing_our_asset_aborts() { } // --------------------------------------------------------------------------- -// Consent (§12). +// Consent. // --------------------------------------------------------------------------- -/// The prompt has to carry everything §12 asks it to say: which path, how big, +/// The prompt has to carry everything it must say: which path, how big, /// and where the bytes came from. #[test] fn the_confirmation_states_path_size_and_origin() { @@ -725,7 +749,7 @@ fn a_present_but_unexecutable_binary_is_reinstalled() { } // --------------------------------------------------------------------------- -// Refusals and write failures (§17). +// Refusals and write failures. // --------------------------------------------------------------------------- /// An architecture we do not publish for is refused before anything is @@ -758,7 +782,7 @@ fn an_unsupported_machine_is_refused_before_any_work() { } /// **A failed remote write reports the path and the server's reason, and is not -/// retried anywhere else** (§17). A full disk must not become "let me try +/// retried anywhere else**. A full disk must not become "let me try /// /tmp". #[test] fn a_failed_write_names_the_path_and_does_not_fall_back() { @@ -1162,3 +1186,489 @@ fn the_published_path_is_absolute_and_version_qualified() { "this is the string `ensure_remote_server` hands the transport" ); } + +// --------------------------------------------------------------------------- +// Progress (an 8 MB first install must not look like a hang). +// --------------------------------------------------------------------------- + +/// Records every report in order, which is what makes "monotonic" and "reaches +/// the total" testable — neither is a property of any single report. +#[derive(Default)] +struct Reports(Mutex>); + +impl InstallProgress for Reports { + fn report(&self, host: &str, phase: InstallPhase) { + self.0.lock().unwrap().push((host.to_string(), phase)); + } +} + +impl Reports { + fn all(&self) -> Vec<(String, InstallPhase)> { + self.0.lock().unwrap().clone() + } + + fn phases(&self) -> Vec { + self.all().into_iter().map(|(_, phase)| phase).collect() + } +} + +/// A release whose asset arrives in pieces, like a real HTTP body. +struct ChunkedRelease { + inner: FakeRelease, + chunks: usize, +} + +impl AssetFetcher for ChunkedRelease { + fn get(&self, url: &str) -> Result, String> { + self.inner.get(url) + } + + fn get_with_progress( + &self, + url: &str, + on_progress: &dyn Fn(u64, Option), + ) -> Result, String> { + let bytes = self.inner.get(url)?; + let total = bytes.len() as u64; + let step = total.div_ceil(self.chunks as u64).max(1); + let mut done = 0; + while done < total { + done = (done + step).min(total); + on_progress(done, Some(total)); + } + Ok(bytes) + } +} + +/// **Both halves of the wait are reported, and each one finishes.** +/// +/// The download and the upload are separate network hops of the same ~8 MB, and +/// a bar that covered only one of them would sit at 100% through the other — +/// which is the exact failure this exists to prevent. +#[test] +fn an_install_reports_both_transfers_to_completion() { + let remote = FakeRemote::new(); + let release = ChunkedRelease { + inner: FakeRelease::new(), + chunks: 4, + }; + let user = FakeUser::approving(); + let reports = Arc::new(Reports::default()); + + let report = with_install_progress(reports.clone(), || { + Installer::new(&remote, &release, &user, "me@build-box:22") + .with_version(VERSION) + .with_timeouts(Duration::from_millis(200), Duration::from_millis(10)) + .run() + }) + .expect("install"); + assert!(report.installed, "the fake remote started empty"); + + let total = SERVER_BYTES.len() as u64; + let phases = reports.phases(); + + let downloads: Vec<(u64, Option)> = phases + .iter() + .filter_map(|p| match p { + InstallPhase::Downloading { done, total } => Some((*done, *total)), + _ => None, + }) + .collect(); + assert!( + downloads.len() > 1, + "a chunked body should report more than once: {downloads:?}" + ); + assert_eq!( + downloads.last().map(|(done, _)| *done), + Some(total), + "the download's last report is the whole asset" + ); + assert!( + downloads.windows(2).all(|w| w[0].0 <= w[1].0), + "a bar that goes backwards reads as a restart: {downloads:?}" + ); + + let uploads: Vec = phases + .iter() + .filter_map(|p| match p { + InstallPhase::Uploading { done, .. } => Some(*done), + _ => None, + }) + .collect(); + assert_eq!( + uploads.last(), + Some(&total), + "the upload reaches the byte count the consent prompt quoted" + ); + + // Order matters: the client cannot push bytes it has not fetched, and a UI + // that saw them interleaved would have to decide which one to draw. + let first_upload = phases + .iter() + .position(|p| matches!(p, InstallPhase::Uploading { .. })) + .expect("an upload"); + let last_download = phases + .iter() + .rposition(|p| matches!(p, InstallPhase::Downloading { .. })) + .expect("a download"); + assert!( + last_download < first_upload, + "downloading finishes before uploading starts: {phases:?}" + ); +} + +/// **Every report names the machine it is about.** +/// +/// The GUI keys its progress slots by machine, so a report that arrived with the +/// wrong label — or an empty one — would paint one box's bytes under another's +/// name while both were installing. +#[test] +fn every_report_carries_the_host() { + let remote = FakeRemote::new(); + let release = ChunkedRelease { + inner: FakeRelease::new(), + chunks: 3, + }; + let user = FakeUser::approving(); + let reports = Arc::new(Reports::default()); + + with_install_progress(reports.clone(), || { + Installer::new(&remote, &release, &user, "me@build-box:22") + .with_version(VERSION) + .with_timeouts(Duration::from_millis(200), Duration::from_millis(10)) + .run() + }) + .expect("install"); + + let hosts: Vec = reports.all().into_iter().map(|(host, _)| host).collect(); + assert!(!hosts.is_empty(), "the install reported something"); + assert!( + hosts.iter().all(|h| h == "me@build-box:22"), + "one install, one machine: {hosts:?}" + ); +} + +/// **An install that is already present reports nothing.** +/// +/// The common path — a machine tty7 has installed to before — does no transfer +/// at all, and a bar that flashed on every connect would train the user to +/// ignore it on the one connect where it means something. +#[test] +fn a_present_binary_reports_no_progress() { + let remote = FakeRemote::new().with_previous_install(VERSION); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + let reports = Arc::new(Reports::default()); + + let report = with_install_progress(reports.clone(), || { + installer(&remote, &release, &user, "me@build-box:22").run() + }) + .expect("install"); + + assert!(!report.installed, "nothing was written"); + assert!( + reports.phases().is_empty(), + "nothing transferred, so nothing to show: {:?}", + reports.phases() + ); +} + +/// **The scoped sink outranks the global one, and is put back afterwards.** +/// +/// Same contract as `with_install_confirm`, and it matters for the same reason: +/// in the daemon each routed connection has its own client, and a global would +/// send one machine's byte counts to the other machine's window. +#[test] +fn a_scoped_progress_sink_outranks_the_global_one() { + let scoped = Arc::new(Reports::default()); + let phase = InstallPhase::Uploading { done: 1, total: 2 }; + + install_progress().report("before", phase); + with_install_progress(scoped.clone(), || { + install_progress().report("inside", phase); + }); + install_progress().report("after", phase); + + let seen: Vec = scoped.all().into_iter().map(|(host, _)| host).collect(); + assert_eq!( + seen, + vec!["inside".to_string()], + "only the reports raised inside the scope land in it" + ); +} + +/// **`fraction` is safe to hand straight to a layout.** +/// +/// It feeds a width, so anything outside `0.0..=1.0` draws a bar that overflows +/// its track or inverts it. A zero or absent total is the interesting case: it +/// means "unknown", not "zero percent", and the caller has to be able to tell. +#[test] +fn a_fraction_is_either_absent_or_in_range() { + assert_eq!( + InstallPhase::Downloading { + done: 0, + total: None + } + .fraction(), + None, + "no Content-Length means no fraction to draw" + ); + assert_eq!( + InstallPhase::Uploading { done: 5, total: 0 }.fraction(), + None, + "a zero total is unknown, not complete" + ); + assert_eq!( + InstallPhase::Uploading { + done: 50, + total: 100 + } + .fraction(), + Some(0.5) + ); + assert_eq!( + InstallPhase::Uploading { + done: 200, + total: 100 + } + .fraction(), + Some(1.0), + "an over-count is clamped rather than overflowing the track" + ); +} + +// --------------------------------------------------------------------------- +// Dialects, not build strings. +// --------------------------------------------------------------------------- + +/// What this client speaks, which is what a remote has to match. +fn ours() -> RemoteProtocol { + RemoteProtocol { + build: VERSION.to_string(), + ..RemoteProtocol::of_this_build() + } +} + +const OTHER_BUILD: &str = "26.7.9-nightly.20260801"; +const OTHER_EXE: &str = "/home/me/.local/share/tty7/bin/tty7-server-26.7.9-nightly.20260801"; + +/// **A newer server this client can talk to is adopted, not overwritten.** +/// +/// The scene from the field: a `26.7.6` client meets a machine already serving +/// `26.7.7-nightly`, both speaking the same dialects. Before this, the client +/// stat'ed for its *own* version, missed, uploaded 8 MB nobody needed, and then +/// asked the user to choose between keeping their sessions and restarting a +/// server that was working fine. +#[test] +fn a_compatible_running_server_is_reused_without_installing() { + let remote = FakeRemote::new().serving(OTHER_EXE).speaking( + OTHER_EXE, + RemoteProtocol { + build: OTHER_BUILD.to_string(), + ..ours() + }, + ); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@build-box:22") + .run() + .expect("connect"); + + assert!( + !report.installed, + "nothing needed writing: {:?}", + remote.writes() + ); + assert!( + report.reused.is_some(), + "the running server was adopted deliberately, and the report says so" + ); + assert_eq!( + report.paths.binary, OTHER_EXE, + "the transport must connect to the binary that is actually serving" + ); + assert!( + report.mismatch.is_none(), + "same dialects, so there is nothing to ask the user about" + ); + assert!( + remote.writes().is_empty(), + "not one byte written to a machine that needed nothing: {:?}", + remote.writes() + ); + assert!( + release.fetched().is_empty(), + "and nothing downloaded either: {:?}", + release.fetched() + ); +} + +/// **A server speaking a different dialect is still installed over.** +/// +/// The other half of the same judgement — adoption is not a blanket "reuse +/// whatever is there". A control dialect we cannot speak is exactly what the +/// prompt exists for. +#[test] +fn an_incompatible_running_server_is_not_adopted() { + let remote = FakeRemote::new().serving(OTHER_EXE).speaking( + OTHER_EXE, + RemoteProtocol { + build: OTHER_BUILD.to_string(), + control: ours().control + 1, + ..ours() + }, + ); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@build-box:22") + .run() + .expect("connect"); + + assert!(report.installed, "a dialect we cannot speak means install"); + assert!(report.reused.is_none()); + assert_eq!( + report.paths.binary, BINARY, + "and the transport uses the one we just installed" + ); + assert!( + report.mismatch.is_some(), + "the user still has a choice to make about the daemon that is running" + ); +} + +/// **The pane dialect counts too, not just the control one.** +/// +/// A remote workspace uses both: control for the workspace, the pane protocol +/// for every terminal in it. Matching one and not the other would open the +/// workspace and then fail on the first pane. +#[test] +fn a_matching_control_dialect_is_not_enough_on_its_own() { + let remote = FakeRemote::new().serving(OTHER_EXE).speaking( + OTHER_EXE, + RemoteProtocol { + build: OTHER_BUILD.to_string(), + protocol: ours().protocol + 1, + ..ours() + }, + ); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@build-box:22") + .run() + .expect("connect"); + + assert!( + report.installed, + "the control versions agreed, but panes would not have worked" + ); + assert!(report.reused.is_none()); +} + +/// **A server too old to answer `--protocol` is handled exactly as before.** +/// +/// It predates the flag, so it exits non-zero; we learn nothing, and "nothing +/// learnt" has to keep meaning "install ours and let the user decide", never +/// "assume it is fine". +#[test] +fn a_server_that_cannot_be_probed_is_installed_over() { + // `.serving` without `.speaking`: the probe fails. + let remote = FakeRemote::new().serving(OTHER_EXE); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@build-box:22") + .run() + .expect("connect"); + + assert!(report.installed, "no answer means no adoption"); + assert!(report.reused.is_none()); + assert!( + report.mismatch.is_some(), + "an unprobeable different build is exactly when the prompt is honest" + ); +} + +/// **Our own version already installed still short-circuits everything.** +/// +/// The fast path must not have grown a probe: a machine we have installed on +/// before should cost a `stat` and nothing more. +#[test] +fn the_matching_version_still_costs_no_probe() { + let remote = FakeRemote::new() + .with_previous_install(VERSION) + .serving(BINARY); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@build-box:22") + .run() + .expect("connect"); + + assert!(!report.installed); + assert!(report.reused.is_none(), "adoption is for *other* builds"); + assert!( + !remote + .journal() + .iter() + .any(|j| matches!(j, Journal::Exec(cmd) if cmd.ends_with(PROTOCOL_FLAG))), + "nothing to ask: the running exe is the path we wanted: {:?}", + remote.journal() + ); +} + +/// **`serves` is symmetric in neither direction by accident — it is equality.** +/// +/// Written down because "newer can serve older" is the tempting wrong rule, and +/// the failure it produces (a wire error mid-session, long after the connect) +/// is far worse than the prompt it avoids. +#[test] +fn only_identical_dialects_serve() { + let base = ours(); + assert!(base.serves(&base)); + assert!( + base.serves(&RemoteProtocol { + build: "some other build entirely".to_string(), + ..base.clone() + }), + "the build string decides nothing" + ); + assert!( + !base.serves(&RemoteProtocol { + control: base.control + 1, + ..base.clone() + }), + "a newer client is not automatically served by an older server" + ); + assert!( + !RemoteProtocol { + control: base.control + 1, + ..base.clone() + } + .serves(&base), + "nor the other way round" + ); +} + +/// **The probe's output survives a chatty login shell.** +/// +/// `.bashrc` on a shared box prints banners, `direnv` prints exports, and all of +/// it lands on the same stdout the JSON does. +#[test] +fn a_noisy_shell_does_not_break_the_probe() { + let spoken = ours(); + let json = serde_json::to_string(&spoken).unwrap(); + + assert_eq!(RemoteProtocol::parse(&json), Some(spoken.clone())); + assert_eq!( + RemoteProtocol::parse(&format!( + "Welcome to build-box!\nLast login: today\n{json}\n" + )), + Some(spoken), + "the answer is the last line, because the server prints it at exit" + ); + assert_eq!(RemoteProtocol::parse(""), None); + assert_eq!(RemoteProtocol::parse("not json at all"), None); +} diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs index a5e2064c..87e07200 100644 --- a/crates/tty7-core/src/daemon/install/wsl.rs +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -1,5 +1,5 @@ //! WSL — a distribution on *this* machine as a remote workspace host -//! (design §7.3, §12 and decision D9). +//! (decision D9). //! //! ## Why WSL is its own transport instead of "just another SSH host" //! @@ -615,7 +615,7 @@ impl RemoteOps for WslRemoteOps { /// /// **Through `wsl.exe -- tee `, not through a `\\wsl$\\…` UNC /// path**, and this is the one design decision in this file that had a real - /// alternative. Design §12 names the UNC path; it is the worse of the two: + /// alternative. The UNC path is the worse of the two: /// /// | | `\\wsl$` UNC write | `tee` over stdio | /// |---|---|---| @@ -677,7 +677,7 @@ impl RemoteOps for WslRemoteOps { } // --------------------------------------------------------------------------- -// The bundled binary (design §12: WSL does not download). +// The bundled binary (WSL does not download). // --------------------------------------------------------------------------- /// Overrides where the bundled Linux server binaries are looked for. Exists so @@ -723,7 +723,8 @@ pub fn bundled_search_dirs(exe: Option<&Path>, override_dir: Option<&Path>) -> V /// The Linux `tty7-server` this client shipped with. /// -/// Design §12: "WSL 的安装不走下载 —— 直接把客户端自带的 Linux 二进制拷过去". +/// A WSL install never downloads: the client's own bundled Linux binary is +/// copied across instead. /// The version question answers itself, because the bundled binary was built /// from the same workspace version as the client asking for it; there is no tag /// to resolve and no manifest to verify against. @@ -984,7 +985,7 @@ mod tests { )); } - /// Design §7.3's command line, spelled out. This is the exact argv the + /// The WSL command line, spelled out. This is the exact argv the /// transport is specified to produce, and it has never been run — so the /// string is pinned here instead. #[test] @@ -1033,7 +1034,7 @@ mod tests { } /// The label a WSL host is prompted about, recorded under, and keyed by has - /// to be the one `RemoteTarget` already defined (contract §4.2) — these are + /// to be the one `RemoteTarget` already defined — these are /// compared as strings in the mismatch registry and in log lines a user is /// meant to correlate. #[test] @@ -1485,7 +1486,7 @@ mod tests { /// The bundled source loads bytes and reports where they came from, and its /// absence is a *named* failure rather than a silent fall back to a - /// download — which is the whole point of §12's WSL exception. + /// download — which is the whole point of the WSL exception. #[test] fn a_missing_bundled_binary_names_every_place_it_looked() { let tmp = std::env::temp_dir().join(format!("tty7-wsl-src-{}", std::process::id())); @@ -1768,8 +1769,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Declining writes nothing. Same rule as SSH — §12's "往别人机器上写二进制 - /// 值得问一次" applies to a distribution too, because it is still a + /// Declining writes nothing. Same rule as SSH — writing a binary onto a + /// machine is worth asking about once, and a distribution is still a /// filesystem the user owns and did not ask us to touch. #[test] fn declining_writes_nothing_into_the_distribution() { diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index e4e17711..cfb1f238 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -748,6 +748,13 @@ struct NativeSshBackend { /// locks. pub struct DaemonPane { pub id: u64, + /// The workspace this pane was spawned for (a `WorkspaceId` uuid string), + /// when the spawning client said — see `ClientMsg::Spawn`'s `owner`. + /// Immutable for the pane's lifetime: ownership is decided at spawn, and a + /// pane that could change hands would be exactly the ambiguity this field + /// exists to close. Reported in `List` ([`PaneInfo::owner`]) so restore can + /// refuse to attach a workspace to a pane another one owns. + owner: Option, /// The byte source (local PTY or native-SSH channel). backend: PaneBackend, /// The input side (keyboard input / pasted text): the PTY writer, or the @@ -839,6 +846,7 @@ impl DaemonPane { cwd: Option, size: WinSize, shell: Option, + owner: Option, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result> { let pty_size = pty_size(size); @@ -880,6 +888,7 @@ impl DaemonPane { let pane = Arc::new(Self { id, + owner, backend: PaneBackend::Pty(PtyBackend { master: master.clone(), child: Mutex::new(child), @@ -1002,6 +1011,10 @@ impl DaemonPane { let pane = Arc::new(Self { id, + // Native-SSH spawns don't carry an owner yet: their leaves persist + // an `ssh_spec` and reconnect from it rather than by pane id, so + // the ownership check has nothing to protect there today. + owner: None, backend: PaneBackend::NativeSsh(NativeSshBackend { handle: bridge.handle, connection: connection.clone(), @@ -1391,6 +1404,7 @@ impl DaemonPane { cwd: cwd.or_else(|| self.foreground_cwd()), title: self.foreground_title(), alive, + owner: self.owner.clone(), } } @@ -2674,6 +2688,7 @@ mod tests { args: vec!["-c".into(), "cd /usr && exec cat".into()], args_are_tty7_defaults: false, }), + None, || {}, ) .expect("spawn pane"); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index ffb17861..8819dd17 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -68,9 +68,16 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024; /// - **v1** — the dialect at the time versioning landed. pub const PROTOCOL_VERSION: u32 = 3; +/// Capability string for [`DaemonVersion::features`]: this daemon records +/// which workspace each pane was spawned for and reports it in `List`'s +/// [`PaneInfo::owner`], and it understands the [`kind::SPAWN_OWNED`] frame. A +/// client must check for this before sending an owned spawn — the frame kind +/// is unknown to older daemons, which drop the connection over it. +pub const FEATURE_PANE_OWNER: &str = "pane-owner"; + /// Reply to `ClientMsg::Version`: the protocol dialect the daemon speaks, plus -/// its crate version for logs/diagnostics. Only `protocol` and `features` drive -/// decisions. +/// its crate version for logs/diagnostics. Only `protocol`, `features` and +/// `instance` drive decisions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonVersion { pub protocol: u32, @@ -86,6 +93,15 @@ pub struct DaemonVersion { /// every user whose daemon happens to predate it. #[serde(default)] pub features: Vec, + /// Identity of this daemon *process*, minted once at startup. Pane ids are + /// only meaningful within one daemon process — after a restart the numbers + /// start over from 1 and land on unrelated shells — so a client that + /// persists pane ids records this next to them and treats a mismatch as + /// "every saved id is stale" (see `Workspace::daemon_instance`). Empty for + /// daemons that predate the field; the remote `tty7-server` announces the + /// same identity through its control hello. + #[serde(default)] + pub instance: String, } impl DaemonVersion { @@ -101,7 +117,11 @@ impl DaemonVersion { // control dialect is served by `tty7-server`, which advertises // `control` / `host-rpc` itself; claiming them here would make the // GUI open a control connection this process cannot answer. - features: Vec::new(), + // + // `pane-owner` *is* a pane-protocol capability, so every process + // serving panes from this build advertises it. + features: vec![FEATURE_PANE_OWNER.to_string()], + instance: process_instance().to_string(), } } @@ -111,6 +131,13 @@ impl DaemonVersion { } } +/// This process's pane-daemon identity: a uuid minted on first use and stable +/// for the process lifetime. See [`DaemonVersion::instance`] for why it exists. +pub fn process_instance() -> &'static str { + static INSTANCE: std::sync::OnceLock = std::sync::OnceLock::new(); + INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string()) +} + /// Terminal geometry shared by spawn/attach/resize. Cell pixel size travels too /// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`), /// which some full-screen apps read. @@ -186,6 +213,14 @@ pub struct PaneInfo { /// False once the child has exited but the pane lingers (so a client can /// still read its final scrollback). pub alive: bool, + /// The workspace this pane was spawned for (a `WorkspaceId` uuid, as a + /// string), when the spawning client said ([`ClientMsg::Spawn`]'s `owner`). + /// `None` for panes spawned by older clients or through the legacy spawn + /// kinds. Restore uses this to refuse re-attaching a pane to a workspace + /// that never owned it — the failure mode where one workspace's saved ids + /// silently pick up another's shells. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, } /// A pane whose filesystem is not the host's — either a remote session, or a @@ -236,7 +271,7 @@ pub struct LoopbackForwardRequest { } // --------------------------------------------------------------------------- -// Workspace-scoped control requests (design §15, M7). +// Workspace-scoped control requests (M7). // // A *remote workspace* has no pane on this daemon: its panes live on the remote // `tty7-server`, and the only thing this side owns is the `SshConnection` the @@ -906,6 +941,12 @@ pub enum ClientMsg { cwd: Option, size: WinSize, shell: Option, + /// The workspace this pane will belong to (a `WorkspaceId` uuid, as a + /// string). Rides the [`kind::SPAWN_OWNED`] frame, which only a daemon + /// advertising [`FEATURE_PANE_OWNER`] understands — callers leave this + /// `None` for older daemons and the spawn goes out on the legacy kinds, + /// byte-for-byte as before. + owner: Option, }, /// Bind this connection to an existing pane and (re)size it. The daemon /// replies with a `Snapshot` then live `Output`. @@ -991,7 +1032,7 @@ pub enum ClientMsg { /// every pane, forever, to feed a view that's usually closed. QueryProcs { pane_id: u64 }, /// A control request scoped to a **remote workspace's** SSH connection - /// instead of a pane's (design §15). See [`WorkspaceRequest`]. + /// instead of a pane's. See [`WorkspaceRequest`]. OnWorkspace(Box), /// Ask which protocol version the daemon speaks (control connection); the /// daemon replies `Version`. A daemon that predates versioning doesn't know @@ -1138,10 +1179,19 @@ mod kind { // here because this module is private and the router must not become a // reason to open it — but the number is spent either way. /// `OnWorkspace` — a control request on a remote workspace's SSH connection - /// (design §15). 52 is the next number clear of every range above, of the + ///. 52 is the next number clear of every range above, of the /// router's 51, and of the retired 13; the contract's control connection /// reserves 60-63, which this stays below. pub const ON_WORKSPACE: u8 = 52; + /// `Spawn` carrying a [`super::OwnedSpawn`] **struct** payload — the spawn + /// that also names the workspace owning the pane. A brand-new kind for the + /// same reason `SPAWN_SHELL` was one: the legacy spawn payloads are + /// positional tuples an old daemon cannot grow, so a client only sends this + /// to a daemon advertising [`super::FEATURE_PANE_OWNER`] and falls back to + /// the legacy kinds otherwise. The struct payload is the lesson learned — + /// any further spawn field rides this kind with `#[serde(default)]`, no new + /// number needed. 53 stays below the control connection's 60-63 reserve. + pub const SPAWN_OWNED: u8 = 53; // Daemon -> client pub const SPAWNED: u8 = 1; @@ -1216,6 +1266,25 @@ pub fn read_frame(r: &mut R) -> io::Result<(u8, Vec)> { Ok((kind[0], payload)) } +/// The kind byte of the frame at the front of `buf`, once its 5-byte header has +/// arrived — the payload need not have. +/// +/// For the one caller that has to classify a reply *before* paying for it: the +/// client's `Attach` is answered either by a tiny `Error` or by a `Size` + +/// `Snapshot` replay that can run to megabytes, and waiting for the whole first +/// frame to tell them apart would stall every successful attach behind its own +/// scrollback. +pub fn peek_frame_kind(buf: &[u8]) -> Option { + (buf.len() >= 5).then(|| buf[4]) +} + +/// Whether `kind` is the [`DaemonMsg::Error`] frame. The kind bytes themselves +/// stay private — this is the one classification a client makes without +/// decoding, and naming it keeps the numbering in one file. +pub fn is_error_kind(kind: u8) -> bool { + kind == kind::ERROR +} + /// Extract one complete frame from the front of `buf`, if fully buffered — the /// resumable counterpart of [`read_frame`] for callers that read the stream /// with timeouts (the client reader enforces the DEC 2026 synchronized-update @@ -1254,6 +1323,19 @@ fn from_json Deserialize<'de>>(bytes: &[u8]) -> io::Result { serde_json::from_slice(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } +/// The [`kind::SPAWN_OWNED`] payload — a struct, not a tuple, so the *next* +/// spawn field is a `#[serde(default)]` line here instead of a new frame kind. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OwnedSpawn { + #[serde(default)] + cwd: Option, + size: WinSize, + #[serde(default)] + shell: Option, + #[serde(default)] + owner: Option, +} + impl ClientMsg { /// Encode and write this message as one frame. pub fn encode(&self, w: &mut W) -> io::Result<()> { @@ -1265,12 +1347,31 @@ impl ClientMsg { cwd, size, shell: None, + owner: None, } => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?), ClientMsg::Spawn { cwd, size, shell: shell @ Some(_), + owner: None, } => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?), + // An owner present means the caller checked FEATURE_PANE_OWNER — + // this frame kind is unknown to daemons without it. + ClientMsg::Spawn { + cwd, + size, + shell, + owner: owner @ Some(_), + } => write_frame( + w, + kind::SPAWN_OWNED, + &to_json(&OwnedSpawn { + cwd: cwd.clone(), + size: *size, + shell: shell.clone(), + owner: owner.clone(), + })?, + ), ClientMsg::Attach { pane_id, size } => { write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?) } @@ -1340,11 +1441,31 @@ impl ClientMsg { cwd, size, shell: None, + owner: None, } } kind::SPAWN_SHELL => { let (cwd, size, shell) = from_json(&payload)?; - ClientMsg::Spawn { cwd, size, shell } + ClientMsg::Spawn { + cwd, + size, + shell, + owner: None, + } + } + kind::SPAWN_OWNED => { + let OwnedSpawn { + cwd, + size, + shell, + owner, + } = from_json(&payload)?; + ClientMsg::Spawn { + cwd, + size, + shell, + owner, + } } kind::ATTACH => { let (pane_id, size) = from_json(&payload)?; @@ -1575,6 +1696,7 @@ mod tests { cwd: Some(PathBuf::from("/work")), size: SIZE, shell: None, + owner: None, }, ClientMsg::Resize(SIZE), ClientMsg::Input(vec![b'l', b's', b'\r']), @@ -1630,11 +1752,13 @@ mod tests { cwd: Some(PathBuf::from("/tmp/x")), size: SIZE, shell: None, + owner: None, }, ClientMsg::Spawn { cwd: None, size: SIZE, shell: None, + owner: None, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1644,6 +1768,13 @@ mod tests { args: vec!["--distribution".into(), "Ubuntu".into()], args_are_tty7_defaults: true, }), + owner: None, + }, + ClientMsg::Spawn { + cwd: Some(PathBuf::from("/tmp/x")), + size: SIZE, + shell: None, + owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()), }, ClientMsg::Attach { pane_id: 42, @@ -1771,12 +1902,22 @@ mod tests { }, DaemonMsg::Exited { code: Some(0) }, DaemonMsg::Exited { code: None }, - DaemonMsg::PaneList(vec![PaneInfo { - pane_id: 3, - cwd: Some(PathBuf::from("/x")), - title: "zsh".into(), - alive: true, - }]), + DaemonMsg::PaneList(vec![ + PaneInfo { + pane_id: 3, + cwd: Some(PathBuf::from("/x")), + title: "zsh".into(), + alive: true, + owner: None, + }, + PaneInfo { + pane_id: 4, + cwd: None, + title: String::new(), + alive: true, + owner: Some("ffe038d0-9ad6-40c0-815d-1fcc43c17ec0".into()), + }, + ]), DaemonMsg::RemoteContext(Some(RemoteContext { kind: RemoteKind::Ssh, argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()], @@ -1899,11 +2040,13 @@ mod tests { protocol: PROTOCOL_VERSION, build: "0.15.0".into(), features: vec!["control".into(), "host-rpc".into()], + instance: "inst-a".into(), }), DaemonMsg::Version(DaemonVersion { protocol: PROTOCOL_VERSION, build: "0.15.0".into(), features: Vec::new(), + instance: String::new(), }), DaemonMsg::Error("nope".into()), ]; @@ -1929,6 +2072,7 @@ mod tests { cwd: Some(PathBuf::from("/work")), size: SIZE, shell: None, + owner: None, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1949,6 +2093,7 @@ mod tests { cwd: Some(PathBuf::from("/old")), size: SIZE, shell: None, + owner: None, } ); } @@ -1967,6 +2112,7 @@ mod tests { cwd: Some(PathBuf::from("/work")), size: SIZE, shell: Some(shell.clone()), + owner: None, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1979,10 +2125,74 @@ mod tests { cwd: Some(PathBuf::from("/work")), size: SIZE, shell: Some(shell), + owner: None, } ); } + /// An owned spawn rides the `SPAWN_OWNED` frame — never a legacy kind, + /// whose tuple payloads cannot carry the field — and round-trips with the + /// shell pick intact. The compat direction is the caller's contract: + /// `owner` is only ever set for a daemon advertising `pane-owner`, so the + /// legacy kinds stay byte-for-byte what old daemons expect (locked by + /// `default_spawn_stays_wire_compatible_with_old_daemons` above). + #[test] + fn owned_spawn_uses_the_owned_kind_and_round_trips() { + let msg = ClientMsg::Spawn { + cwd: Some(PathBuf::from("/work")), + size: SIZE, + shell: Some(ShellSpec { + program: "fish".into(), + args: vec!["-l".into()], + args_are_tty7_defaults: false, + }), + owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()), + }; + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + let (k, payload) = read_frame(&mut std::io::Cursor::new(&buf)).unwrap(); + assert_eq!(k, kind::SPAWN_OWNED); + assert_eq!(ClientMsg::from_frame(k, payload).unwrap(), msg); + } + + /// The `SPAWN_OWNED` payload is a struct with defaults, so a frame from a + /// *newer* client — more fields, or fewer — still decodes. This is the + /// property that makes it the last spawn kind ever needed. + #[test] + fn owned_spawn_payload_tolerates_unknown_and_missing_fields() { + let payload = serde_json::to_vec(&serde_json::json!({ + "size": {"cols": 80, "rows": 24, "cell_w": 8, "cell_h": 17}, + "some_future_field": true, + })) + .unwrap(); + let decoded = ClientMsg::from_frame(kind::SPAWN_OWNED, payload).unwrap(); + assert_eq!( + decoded, + ClientMsg::Spawn { + cwd: None, + size: WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 17 + }, + shell: None, + owner: None, + } + ); + } + + /// A `PaneInfo` from an old daemon has no `owner` key and decodes to + /// `None` — the "attachable by anyone" reading every pane had before the + /// field existed. + #[test] + fn pane_info_owner_defaults_for_old_daemons() { + let old = serde_json::json!({"pane_id": 3, "title": "zsh", "alive": true}); + let info: PaneInfo = serde_json::from_value(old).unwrap(); + assert_eq!(info.owner, None); + assert!(info.alive); + } + /// An empty-payload binary frame (e.g. an `Input([])`) still round-trips and /// an oversize length is rejected. #[test] diff --git a/crates/tty7-core/src/daemon/remote_link.rs b/crates/tty7-core/src/daemon/remote_link.rs index 40c13746..a8d8d6f0 100644 --- a/crates/tty7-core/src/daemon/remote_link.rs +++ b/crates/tty7-core/src/daemon/remote_link.rs @@ -104,8 +104,7 @@ impl RemoteLink { Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?)) } - /// Spawn `wsl.exe -d -- --stdio` and take its stdio - /// (design §7.3). + /// Spawn `wsl.exe -d -- --stdio` and take its stdio. /// /// `server` is an **absolute path inside the distribution**, not a bare /// name: `wsl.exe` runs the command without a login shell, so the `PATH` @@ -235,7 +234,7 @@ pub const DEFAULT_REMOTE_SERVER_CMD: &str = "tty7-server --stdio"; const MAX_SOCKET_PATH_BYTES: usize = 100; /// How this connection reaches the remote `tty7-server` — decided once per SSH -/// connection and cached there (design §7.1), never re-decided per channel. +/// connection and cached there, never re-decided per channel. /// /// Probing per channel would put a failed `direct-streamlocal` open in front of /// every pane on a host whose admin turned `AllowStreamLocalForwarding` off, @@ -363,7 +362,7 @@ impl RemoteEnv { /// bridge that resolves the path in the process that binds it. /// /// Paths are joined as POSIX strings, never `PathBuf`: on a Windows client -/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7` (contract §4.3). +/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7`. pub fn remote_control_socket(env: &RemoteEnv) -> Option { if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) { return Some(explicit.to_string()); diff --git a/crates/tty7-core/src/daemon/router.rs b/crates/tty7-core/src/daemon/router.rs index a17e7c67..862c54d6 100644 --- a/crates/tty7-core/src/daemon/router.rs +++ b/crates/tty7-core/src/daemon/router.rs @@ -1,5 +1,5 @@ //! [`RemoteRouter`] — the local daemon as a forwarding hub for remote -//! workspaces (design §6). +//! workspaces. //! //! ## The shape //! @@ -18,7 +18,7 @@ //! //! A router that parsed the stream would become a third opinion about the //! protocol version. The remote's dialect is negotiated between the GUI and the -//! remote server — contract §6.9's end-to-end handshake, and the reason the +//! remote server — the end-to-end handshake, and the reason the //! contract resolves erratum #15 the way it does: a local daemon that had to //! understand remote frames would need to be upgraded in lockstep with both //! ends, and every version skew would land in the middle where neither user nor @@ -74,7 +74,7 @@ //! //! [`RouteAction::RestartServer`] uses the same window in the opposite //! direction: the *client* tells the daemon to replace the `tty7-server` on the -//! target machine (design §12's "restart the service"). It is here for the same +//! target machine ("Restart Server"). It is here for the same //! reason the prompts are — the decision needs a user and the act needs an //! `Arc`, and those live in different processes — and it fits the //! window's own rule, being a precondition for a usable link rather than @@ -90,7 +90,8 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::daemon::install::{ - InstallConfirm, InstallDecision, InstallRequest, MismatchedRemoteDaemon, + InstallConfirm, InstallDecision, InstallPhase, InstallProgress, InstallRequest, + MismatchedRemoteDaemon, }; use crate::daemon::protocol::{self, AuthPromptKind, AuthResponse, DaemonMsg, NativeSshSpec}; use crate::daemon::remote_link::RemoteLink; @@ -180,7 +181,7 @@ pub enum RouteTarget { /// A host reached over SSH: `direct-streamlocal` when the server allows it, /// `tty7-server --stdio` on a session channel when it does not. Ssh(Box), - /// A WSL distribution — no SSH, no auth, no network (design §7.3, D9). + /// A WSL distribution — no SSH, no auth, no network (D9). /// /// The distro name as `wsl.exe -l -q` prints it. Nothing else is carried, /// because nothing else exists: no user (the distribution's default user is @@ -199,7 +200,7 @@ pub enum RouteTarget { /// /// Everything else in this module assumes [`Forward`](RouteAction::Forward) — /// the connection is a pipe and the daemon is in the middle of it. The one thing -/// that is *not* a pipe is design §12's "restart the service": it needs the +/// that is *not* a pipe is "Restart Server": it needs the /// machine's `Arc`, which exists only in the daemon process, /// while the decision to do it can only be made by the process with a user in /// front of it. So it travels the same way every other cross-process question on @@ -224,7 +225,7 @@ pub enum RouteAction { /// and no link is opened: there is nothing to talk to afterwards, since the /// daemon that was serving is the one being replaced. /// - /// Design §12: this drops every pane that daemon hosts. It happens only when + /// This drops every pane that daemon hosts. It happens only when /// a user has answered the keep-or-restart prompt with "Restart Server". RestartServer, } @@ -251,7 +252,7 @@ pub struct RouteHeader { /// What the daemon should do with this connection. /// /// `#[serde(default)]` = [`RouteAction::Forward`], the only thing a routed - /// connection did before design §12's restart needed a way across the + /// connection did before the restart needed a way across the /// process boundary. #[serde(default)] pub action: RouteAction, @@ -296,7 +297,7 @@ impl RouteHeader { } /// The same machine, but asking the daemon to replace the `tty7-server` - /// running there rather than to talk to it (design §12). + /// running there rather than to talk to it. /// /// The connection carries nothing afterwards: the ack is the whole /// conversation. Callers must have a user's explicit "Restart Server" behind @@ -452,7 +453,7 @@ pub enum RoutePrompt { request_id: u64, prompt: AuthPromptKind, }, - /// "May tty7 write a server binary onto this machine?" (design §12). + /// "May tty7 write a server binary onto this machine?". Install { request_id: u64, request: Box, @@ -464,6 +465,12 @@ pub enum RoutePrompt { Mismatch { daemons: Vec, }, + /// How far the install this connection is performing has got. Told, not + /// asked, like `Mismatch` — but unlike it, **freely droppable**: these + /// arrive hundreds of times per install and each one supersedes the last, so + /// a client that misses some has lost nothing a later frame will not + /// correct. + InstallProgress { host: String, phase: InstallPhase }, } /// The client's answer to a [`RoutePrompt`]. @@ -524,7 +531,7 @@ impl RouteReply { /// **`machine` is the connection's own target**, handed down from the /// [`RouteHeader`] this negotiation opened with. A client with more than one /// machine has to know which one is asking — to name it in the sheet, and to -/// queue one sheet per machine (design §10, D7) — and the header is the only +/// queue one sheet per machine (D7) — and the header is the only /// place that fact is certain. It used to be inferred from the answering /// *thread*, which held for the workspace connect and silently did not for a /// pane's (`connect_routed` never set it), leaving every routed pane prompt @@ -633,6 +640,13 @@ fn answer(machine: &RouteTarget, prompt: RoutePrompt) -> Option { crate::daemon::install::record_remote_mismatches(daemons); None } + RoutePrompt::InstallProgress { host, phase } => { + // Into this process's sink, which in the GUI is what the switcher + // reads. Same shape as `Mismatch`: relayed precisely so it lands on + // the side with a user on it. + crate::daemon::install::install_progress().report(&host, phase); + None + } } } @@ -730,6 +744,10 @@ pub struct RouteSetup { /// Install consent, in the shape [`crate::daemon::install::Installer`] /// already speaks. pub confirm: Arc, + /// Where that install's byte counts go. Separate from `confirm` even though + /// one `Relay` is both, because [`unattended`](RouteSetup::unattended) wants + /// a sink that discards and a confirm that refuses — two different defaults. + pub progress: Arc, /// Where a build mismatch discovered during setup is collected, to be /// handed to the client instead of to this process's registry. pub mismatches: Arc>>, @@ -745,6 +763,7 @@ impl RouteSetup { RouteSetup { broker: PromptBroker::new(Box::new(|_| false)), confirm: Arc::new(crate::daemon::install::DenyInstall), + progress: Arc::new(crate::daemon::install::SilentProgress), mismatches: Arc::new(Mutex::new(Vec::new())), channel, } @@ -764,10 +783,13 @@ impl RouteSetup { T: Send + 'static, { let confirm = self.confirm.clone(); + let progress = self.progress.clone(); let sink = self.mismatches.clone(); tokio::task::spawn_blocking(move || { crate::daemon::install::with_install_confirm(confirm, || { - crate::daemon::install::with_mismatch_sink(sink, f) + crate::daemon::install::with_install_progress(progress, || { + crate::daemon::install::with_mismatch_sink(sink, f) + }) }) }) .await @@ -838,6 +860,26 @@ impl InstallConfirm for Relay { } } +impl InstallProgress for Relay { + /// Fire-and-forget onto the outbox, with no reply to wait for and no error + /// path — the send fails only once the client has gone, and an install that + /// nobody is watching any more should carry on rather than abort over a + /// progress frame. + /// + /// The outbox is unbounded, so this never blocks the thread pushing bytes + /// over SFTP. The frames are small (tens of bytes) and the writer drains + /// them between transfer chunks. + fn report(&self, host: &str, phase: InstallPhase) { + let prompt = RoutePrompt::InstallProgress { + host: host.to_string(), + phase, + }; + if let Ok(payload) = serde_json::to_vec(&prompt) { + let _ = self.out.send((ROUTE_PROMPT_KIND, payload)); + } + } +} + /// The forwarding hub. Stateless: a routed connection's only state is the two /// halves of the pipe, and both die with it. pub struct RemoteRouter; @@ -887,6 +929,7 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { _ => true, })), confirm: relay.clone(), + progress: relay.clone(), mismatches: Arc::new(Mutex::new(Vec::new())), channel: header.channel, }; @@ -939,7 +982,7 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { // but dropping it would be a silent truncation. Some((link, conn, frames.into_buffer())) } - // Design §12's restart: the daemon that would have been on the other + // The restart: the daemon that would have been on the other // end of this pipe is the one that was just replaced, so the ack is // the last thing this connection carries. The client reconnects to // the new one on its own — the supervisor's reconnect is already the @@ -1084,7 +1127,7 @@ async fn perform(header: &RouteHeader, setup: &RouteSetup) -> anyhow::Result) -> anyhow::Result<()> { let first = ClientMsg::from_frame(first_kind, first_payload)?; match first { - ClientMsg::Spawn { cwd, size, shell } => { + ClientMsg::Spawn { + cwd, + size, + shell, + owner, + } => { let id = registry.alloc_id(); // Reclaim a pane whose child exits while *detached* (nobody attached, // so no connection's detach path will ever drop it): remove it from @@ -272,7 +277,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .ok(); } }; - let pane = match DaemonPane::spawn(id, cwd, size, shell, on_dead) { + let pane = match DaemonPane::spawn(id, cwd, size, shell, owner, on_dead) { Ok(p) => p, Err(e) => { // Report the failure to the client and close. @@ -540,7 +545,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { // A remote workspace has no pane here to address, so its forwards and // SFTP go through one envelope that names the connection instead - // (design §15). The whole answer — including every failure — is built by + //. The whole answer — including every failure — is built by // `ssh::workspace::handle`, so this arm stays a pipe. ClientMsg::OnWorkspace(req) => { let mut w = write_stream; diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index d5632fde..d6becab1 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -66,6 +66,42 @@ pub fn take_mismatched_daemon() -> Option { MISMATCHED_DAEMON.lock().ok()?.take() } +/// What the version handshake learned about the daemon currently serving this +/// process's endpoint. Refreshed by every [`ensure_running`] — including the +/// one `RemoteTerminal`'s spawn retry runs after a daemon death — and cleared +/// when the daemon predates the handshake, so a reader never acts on the +/// identity of a daemon that is no longer the one answering. +static LOCAL_DAEMON: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// The serving daemon's process identity, when it reports one. `None` means +/// "unknown" (an older daemon, or nothing running) — callers must treat that +/// as "no instance check possible", never as a mismatch. +pub fn local_daemon_instance() -> Option { + let guard = LOCAL_DAEMON.lock().ok()?; + guard + .as_ref() + .map(|v| v.instance.clone()) + .filter(|i| !i.is_empty()) +} + +/// Whether the serving daemon advertises `feature` +/// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when +/// nothing is known — the safe answer, because every capability gated on this +/// has a legacy fallback. +pub fn local_daemon_supports(feature: &str) -> bool { + LOCAL_DAEMON + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|v| v.has_feature(feature))) + .unwrap_or(false) +} + +fn note_local_daemon(version: Option) { + if let Ok(mut slot) = LOCAL_DAEMON.lock() { + *slot = version; + } +} + /// How a live daemon answered the version handshake. #[derive(Debug, PartialEq, Eq)] enum VersionProbe { @@ -96,7 +132,10 @@ pub fn ensure_running() -> anyhow::Result<()> { // panes either way. if let Ok(mut stream) = transport::connect() { match query_daemon_version(&mut stream) { - VersionProbe::Speaks(v) if v.protocol == PROTOCOL_VERSION => return Ok(()), + VersionProbe::Speaks(v) if v.protocol == PROTOCOL_VERSION => { + note_local_daemon(Some(v)); + return Ok(()); + } VersionProbe::Speaks(v) => { log::warn!( "daemon (build {}) speaks protocol {}, this build needs {}; \ @@ -105,6 +144,9 @@ pub fn ensure_running() -> anyhow::Result<()> { v.protocol, PROTOCOL_VERSION ); + // Still the serving daemon: its identity and capability list + // are true regardless of the dialect gap. + note_local_daemon(Some(v.clone())); if let Ok(mut slot) = MISMATCHED_DAEMON.lock() { *slot = Some(MismatchedDaemon { version: Some(v) }); } @@ -114,6 +156,7 @@ pub fn ensure_running() -> anyhow::Result<()> { log::warn!( "daemon predates protocol versioning; keeping it and deferring to the user" ); + note_local_daemon(None); if let Ok(mut slot) = MISMATCHED_DAEMON.lock() { *slot = Some(MismatchedDaemon { version: None }); } @@ -121,6 +164,7 @@ pub fn ensure_running() -> anyhow::Result<()> { } VersionProbe::Unresponsive => { log::info!("daemon did not answer the version handshake; restarting it"); + note_local_daemon(None); drop(stream); // `stop` shuts the old daemon down gracefully (`Shutdown` // predates versioning, so even the oldest daemon honors it), @@ -154,7 +198,14 @@ pub fn ensure_running() -> anyhow::Result<()> { // (via `bind`) slightly before the accept loop is ready. let deadline = Instant::now() + STARTUP_TIMEOUT; loop { - if transport::connect().is_ok() { + if let Ok(mut stream) = transport::connect() { + // Capture the fresh daemon's identity (instance + features). It is + // our own build, but asking beats assuming — and this is the only + // handshake a cold start ever runs. + match query_daemon_version(&mut stream) { + VersionProbe::Speaks(v) => note_local_daemon(Some(v)), + _ => note_local_daemon(None), + } return Ok(()); } if Instant::now() >= deadline { @@ -619,6 +670,7 @@ mod tests { protocol: PROTOCOL_VERSION, build: "test".into(), features: Vec::new(), + instance: "inst-test".into(), }) .encode(&mut daemon) .unwrap(); diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs index ab77987e..d555a6a3 100644 --- a/crates/tty7-core/src/daemon/ssh/forward.rs +++ b/crates/tty7-core/src/daemon/ssh/forward.rs @@ -20,7 +20,7 @@ //! connection alive exactly like `ssh -N`. //! //! There are two owners, because tty7 has two unrelated features that both open -//! forwards (design §2): +//! forwards: //! //! | | SSH pane ("连一下") | remote workspace ("在上面开发") | //! |---|---|---| @@ -410,7 +410,7 @@ impl SshForwardRegistry { self.teardown_owned(&ForwardOwner::Pane(pane_id)).await; } - // ---- Workspace-owned forwards (remote workspaces, design §15) ----------- + // ---- Workspace-owned forwards (remote workspaces) ---------------------- /// [`establish`](Self::establish) for a remote workspace. `view_pane` is only /// stamped into the returned row for the GUI's per-pane list; ownership — and @@ -718,7 +718,7 @@ impl SshForwardRegistry { } /// [`ensure_loopback`](Self::ensure_loopback) for a remote workspace: the - /// ⌘-clicked `localhost:PORT` in a remote-workspace pane (design §15). + /// ⌘-clicked `localhost:PORT` in a remote-workspace pane. /// /// The forward is owned by the workspace, so clicking the link in one pane /// and then closing that pane leaves the browser tab working. @@ -890,7 +890,7 @@ impl SshManager { } /// Ensure the on-demand loopback forward behind a ⌘-clicked `localhost:PORT` - /// in a remote-workspace pane (design §15). + /// in a remote-workspace pane. pub fn ensure_workspace_loopback( &self, workspace: WorkspaceId, @@ -1233,7 +1233,7 @@ mod tests { ); } - /// **Remote-workspace ownership (design §15).** The panes of a remote + /// **Remote-workspace ownership.** The panes of a remote /// workspace are transient — a tab closed, a pane respawned after a reconnect /// — so a forward the user ⌘-clicked into existence must outlive them. Only /// closing the *workspace* collects it. diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 50dd3574..49c2dc68 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -20,7 +20,7 @@ pub mod forward; pub mod known_hosts; pub mod session; pub mod sftp; -/// Workspace-scoped control requests (design §15) — see `workspace::handle`. +/// Workspace-scoped control requests — see `workspace::handle`. pub mod workspace; mod auth; @@ -393,7 +393,7 @@ impl SshManager { /// [`ConnectionKey`] registry the SSH panes use, so a workspace opened /// against a host the user already has a pane on costs no prompt at all, and /// a second workspace on the same host costs no second prompt — each stream - /// is a new *channel*, never a new authentication (design §7.1). One channel + /// is a new *channel*, never a new authentication. One channel /// per pane, one per workspace control stream; no multiplexing of our own on /// top of SSH's. /// @@ -499,7 +499,7 @@ impl SshManager { } /// Replace the `tty7-server` running on `spec`'s host with this client's - /// build — design §12's "restart the service", and **it drops every pane + /// build — "Restart Server", and **it drops every pane /// that server is hosting**. /// /// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction) diff --git a/crates/tty7-core/src/daemon/ssh/session.rs b/crates/tty7-core/src/daemon/ssh/session.rs index f7b30e1b..0e9ce72e 100644 --- a/crates/tty7-core/src/daemon/ssh/session.rs +++ b/crates/tty7-core/src/daemon/ssh/session.rs @@ -267,7 +267,7 @@ pub struct SshConnection { remote_forwards: RemoteForwardTable, alive: AtomicBool, /// How this host's `tty7-server` is reached — probed once, then reused by - /// every remote workspace stream on this connection (design §7.1). + /// every remote workspace stream on this connection. /// /// Per *connection*, not per channel: deciding costs a round trip (an `exec` /// to read the remote's environment, and on a host with @@ -356,7 +356,7 @@ impl SshConnection { } /// Open a `direct-streamlocal@openssh.com` channel to `socket_path` on the - /// remote — the preferred way into a remote `tty7-server` (design §7.1). + /// remote — the preferred way into a remote `tty7-server`. /// /// The remote's sshd connects the channel to that Unix socket itself, so the /// far end sees an ordinary local connection and needs no extra process. The diff --git a/crates/tty7-core/src/daemon/ssh/sftp.rs b/crates/tty7-core/src/daemon/ssh/sftp.rs index 56dd020f..083f9afa 100644 --- a/crates/tty7-core/src/daemon/ssh/sftp.rs +++ b/crates/tty7-core/src/daemon/ssh/sftp.rs @@ -52,7 +52,7 @@ use crate::daemon::protocol::{ use super::{ConnectionKey, SshConnection, SshManager}; -/// Chunk size for streaming reads/writes (matches the Tabby reference, §6). +/// Chunk size for streaming reads/writes (matches the Tabby reference). const CHUNK: usize = 256 * 1024; /// How long a finished job's final progress lingers for the GUI to observe before @@ -388,11 +388,20 @@ impl SftpManager { /// server that runs out of disk reports it on the write or the close, and /// swallowing that would leave a truncated file for the caller to chmod and /// rename into place as though it were whole. + /// `on_progress` is called with the running total after each chunk lands. + /// It runs on the SSH runtime between writes, so it must not block — the + /// installer's sink just stores the number. + /// + /// Counted after `write_all` rather than before, so the figure is bytes the + /// transport has accepted rather than bytes we intend to send. It still + /// reaches `len` before `flush`/`shutdown` have confirmed anything, which is + /// why a full bar is not the installer's success signal — the `Ok` is. pub fn put_bytes( &self, conn: &Arc, path: &str, bytes: &[u8], + on_progress: &(dyn Fn(u64) + Send + Sync), ) -> Result<(), String> { SshManager::global().handle().block_on(async { self.with_session(conn, |sftp| async move { @@ -401,8 +410,11 @@ impl SftpManager { .open_with_flags(path.to_string(), flags) .await .map_err(|e| format!("{e}"))?; + let mut written = 0u64; for chunk in bytes.chunks(CHUNK) { file.write_all(chunk).await.map_err(|e| format!("{e}"))?; + written += chunk.len() as u64; + on_progress(written); } file.flush().await.map_err(|e| format!("{e}"))?; file.shutdown().await.map_err(|e| format!("{e}"))?; diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs index 55b74fd3..a16c94ad 100644 --- a/crates/tty7-core/src/daemon/ssh/workspace.rs +++ b/crates/tty7-core/src/daemon/ssh/workspace.rs @@ -1,6 +1,6 @@ -//! Workspace-scoped control requests (design §15, M7). +//! Workspace-scoped control requests (M7). //! -//! A *remote workspace* (design §2, "在上面开发") has no pane on this daemon: its +//! A *remote workspace* has no pane on this daemon: its //! panes live on the remote `tty7-server` and reach it through a routed byte //! pipe. What this side owns is the [`SshConnection`] that pipe rides — the same //! connection an SSH pane to that host would have used, deduplicated by diff --git a/crates/tty7-core/src/host/conformance.rs b/crates/tty7-core/src/host/conformance.rs index fb7efd9a..13e1b7df 100644 --- a/crates/tty7-core/src/host/conformance.rs +++ b/crates/tty7-core/src/host/conformance.rs @@ -118,6 +118,8 @@ macro_rules! for_each_host_case { search_skips_ignored_dirs, search_respects_limit, search_respects_max_dirs, + // machine inventory + shells_are_named_and_have_a_default, // watch watch_reports_create_and_delete, watch_is_non_recursive, @@ -1041,6 +1043,40 @@ pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(hit_names(&hits), vec!["needle.txt"]); } +// --------------------------------------------------------------------------- +// machine inventory +// --------------------------------------------------------------------------- + +/// Every row of the new-tab dropdown is launchable and labelled, and the menu +/// knows which one is the default. +/// +/// Deliberately not "the list is non-empty": a host with no shell registered +/// anywhere is a strange machine, not a broken `Host` implementation. What the +/// dropdown cannot survive is a blank row, a row with nothing to spawn, or two +/// rows with the same name — the dedupe the local probe does is part of the +/// contract, not an implementation detail of `/etc/shells` parsing. +pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) { + let inv = h.shells().expect("a host can list its shells"); + assert!( + !inv.default_name.trim().is_empty(), + "no default shell name to tag the menu with" + ); + let mut seen = std::collections::HashSet::new(); + for shell in &inv.shells { + assert!(!shell.label.trim().is_empty(), "a shell row with no label"); + assert!( + !shell.program.trim().is_empty(), + "shell {:?} has nothing to spawn", + shell.label + ); + assert!( + seen.insert(shell.label.clone()), + "{:?} is listed twice", + shell.label + ); + } +} + // --------------------------------------------------------------------------- // watch // --------------------------------------------------------------------------- diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs index 45e9aae1..a292089f 100644 --- a/crates/tty7-core/src/host/local.rs +++ b/crates/tty7-core/src/host/local.rs @@ -29,8 +29,8 @@ use notify::{RecursiveMode, Watcher}; use crate::core::git; use crate::core::gitignore::GitignoreChain; use crate::host::{ - Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, WatchHandle, WatchSub, - guard_off_ui, + Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle, + WatchSub, guard_off_ui, }; /// How long changes are collected before a batch is delivered. Matched exactly @@ -329,6 +329,11 @@ impl Host for LocalHost { git::git_output(cwd, args) } + fn shells(&self) -> io::Result { + guard_off_ui(); + Ok(crate::core::shells::inventory()) + } + fn watch(&self, dirs: &[PathBuf]) -> io::Result { guard_off_ui(); local_watch(dirs, Arc::clone(&self.gitignore)) diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index 35c99f09..4c916cc8 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -12,8 +12,8 @@ //! //! # Blocking on purpose //! -//! Every method blocks. That is a decision, not an oversight -//! (`docs/2026-07-27-remote-workspace-impl-contract.md` §1): the trait has to be +//! Every method blocks. That is a decision, not an oversight: the trait +//! has to be //! object-safe because the whole tree holds `Arc`, the server side //! serves these same calls from a blocking thread pool, and a GPUI //! `&mut Context` cannot be held across an `.await` anyway — so making the @@ -46,6 +46,8 @@ use std::sync::Arc; use std::sync::OnceLock; use std::thread::ThreadId; +pub use crate::core::shells::ShellInventory; + // --------------------------------------------------------------------------- // Identity // --------------------------------------------------------------------------- @@ -456,7 +458,7 @@ pub trait Host: Send + Sync + 'static { /// conflict prompt. The post-write metadata is the write's own answer, so /// it closes the window by construction. It is also one round trip instead /// of two on every remote save; the control reply already carried it - /// (contract §6.4), so nothing on the wire moved. + ///, so nothing on the wire moved. /// /// Callers that genuinely don't want it write `.map(|_| ())`. fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result; @@ -503,6 +505,18 @@ pub trait Host: Send + Sync + 'static { /// `GIT_WORK_TREE` cleared, and both output streams captured. fn git(&self, cwd: &Path, args: &[&str]) -> io::Result; + // ----- machine inventory ----------------------------------------------- + + /// The shells this host can launch, plus which one a plain new tab lands + /// on — the new-tab dropdown's menu. + /// + /// On the trait rather than beside `detect_shells` because a window bound to + /// a remote workspace opens its tabs *over there*: a picker built from this + /// computer's `/etc/shells` offers paths that don't exist on the machine the + /// spawn actually reaches. Probing is not free (Windows enumerates WSL by + /// spawning `wsl.exe`), so callers ask once per machine, not per menu open. + fn shells(&self) -> io::Result; + // ----- watching -------------------------------------------------------- /// Open a long-lived, non-recursive watch over `dirs` (which may be empty — diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 5a997376..f7ec17db 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -44,7 +44,7 @@ use crate::daemon::control::{ ReplyOk, }; use crate::host::{ - Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, WatchHandle, WatchSub, + Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle, WatchSub, }; /// A [`Host`] backed by a control connection to another machine. @@ -357,6 +357,17 @@ impl Host for RemoteHost { } } + /// Safe to send unguarded: the request landed in control v2, and the + /// handshake already refused any peer on another dialect. A server too old + /// to know the variant is never on the other end of a live connection — it + /// was replaced at install time, or the connection never opened. + fn shells(&self) -> io::Result { + match self.call(ControlRequest::Shells)? { + ReplyOk::Shells(inv) => Ok(inv), + other => Err(wrong_shape("a shell inventory", &other)), + } + } + fn watch(&self, dirs: &[PathBuf]) -> io::Result { let id = match self.call(ControlRequest::WatchOpen { dirs: wire_paths(dirs), @@ -593,6 +604,7 @@ mod tests { crate::daemon::control::feature::CONTROL.into(), crate::daemon::control::feature::HOST_RPC.into(), ], + instance: "test-instance".into(), } } @@ -676,6 +688,33 @@ mod tests { (host, seen_rx) } + /// The dropdown of a remote window is built from the *server's* shells. + /// This is the whole point: a menu filled from the client's `/etc/shells` + /// offers `/bin/zsh` on a box whose zsh lives elsewhere, and every pick + /// fails to spawn. + #[test] + fn shells_come_from_the_peer() { + let (host, seen) = host_with_peer('/', |req| match req { + ControlRequest::Shells => Some(( + ControlReply::Ok(ReplyOk::Shells(crate::core::shells::ShellInventory { + shells: vec![crate::core::shells::DetectedShell { + label: "zsh".into(), + program: "/usr/bin/zsh".into(), + args: vec![], + }], + default_name: "zsh".into(), + })), + vec![], + )), + other => panic!("unexpected request {other:?}"), + }); + + let inv = host.shells().unwrap(); + assert_eq!(seen.recv().unwrap(), ControlRequest::Shells); + assert_eq!(inv.default_name, "zsh"); + assert_eq!(inv.shells[0].program, "/usr/bin/zsh"); + } + /// Path arithmetic follows the *peer's* separator, not the client's. On a /// Windows client this is the difference between `/home/me/src` and /// `/home/me\src`, and between "absolute" and "drive-relative". diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index f380d8d2..3d66ff98 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -8,7 +8,7 @@ //! carries nothing else. //! //! That symmetry is not an accident, it is the reason the trait is blocking -//! (contract §1). A server handler runs on a thread pool, where blocking is what +//!. A server handler runs on a thread pool, where blocking is what //! you want, so the identical `LocalHost` that answers a local file tree answers //! a remote one — no async mirror of every method, and no second implementation //! to drift. @@ -124,10 +124,10 @@ impl Services { } // --------------------------------------------------------------------------- -// Attachment / takeover (design §10, D8) +// Attachment / takeover (D8) // --------------------------------------------------------------------------- -/// The live half of design §10's attachment record. +/// The live half of the attachment record. /// /// [`Attachment`](crate::core::workspace_store::Attachment) in the store is the /// *data* — token, hostname, since — and answers "who holds this workspace". @@ -183,7 +183,7 @@ struct Evicted { /// Whether the link exists *for* this workspace, and so should be closed /// with it. /// - /// Design §10 says to close the displaced session's streams. When the + /// The displaced session's streams are closed. When the /// connection was opened for one workspace — its hello named it — that is /// exactly right, and it is the strong form of the guarantee: the old client /// cannot write again even if it is wedged or hostile. @@ -473,7 +473,7 @@ fn is_disconnect(e: &io::Error) -> bool { /// The reply goes out **even on a mismatch**, which is the only reason the /// client can say "the server speaks v2, I speak v1" instead of "the connection /// dropped" — a `HELLO` carries no `req_id`, so there is no error reply to hang -/// the mismatch on (contract §6.7). +/// the mismatch on. fn handshake( r: &mut R, sink: &Sink, @@ -513,6 +513,7 @@ fn handshake( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(), features, + instance: crate::daemon::control::server_instance().to_string(), }))?; if hello.control_version != CONTROL_VERSION { @@ -529,7 +530,7 @@ fn handshake( /// connections apart even when the same client opens both. static NEXT_CONN: AtomicU64 = AtomicU64::new(1); -/// Design §10's takeover, server side: claim `workspace` for this connection and +/// The takeover, server side: claim `workspace` for this connection and /// tell whoever held it. /// /// The order is the whole behaviour. The store's record moves first (so a @@ -667,7 +668,7 @@ fn submit(conn: &Arc, req_id: u64, req: ControlRequest, blob: Vec) { fn run_job(conn: &Arc, req_id: u64, req: ControlRequest, blob: Vec) { // Cheap pre-check: a request cancelled before a worker picked it up is not // worth running at all. (Once it *has* started there is nothing to do — a - // filesystem call is not interruptible, so §6.8's "best effort, not + // filesystem call is not interruptible, so "best effort, not // guaranteed" is discharged by discarding the result.) if conn.is_cancelled(req_id) { conn.forget(req_id); @@ -811,6 +812,9 @@ fn run_request( (ReplyOk::Output(h.git(&p(&cwd), &borrowed)?), Vec::new()) } + // ----- machine inventory --------------------------------------------- + ControlRequest::Shells => (ReplyOk::Shells(h.shells()?), Vec::new()), + // ----- watch --------------------------------------------------------- ControlRequest::WatchOpen { dirs } => { let id = conn.open_watch(req_id, &paths(&dirs))?; @@ -869,7 +873,7 @@ fn run_request( (ReplyOk::Unit, Vec::new()) } - // ----- attachment (design §10, D8) ----------------------------------- + // ----- attachment (D8) ----------------------------------- ControlRequest::WorkspaceAttach { id } => ( ReplyOk::Attached { took_over_from: attach_workspace(conn, &id, false)?, @@ -2004,6 +2008,9 @@ mod tests { stderr: Vec::new(), }) } + fn shells(&self) -> io::Result { + self.inner.shells() + } fn watch(&self, dirs: &[PathBuf]) -> io::Result { self.inner.watch(dirs) } @@ -2186,6 +2193,7 @@ mod tests { separator: '/', home: "/root".into(), features: vec![], + instance: "other-instance".into(), }) .encode(&mut s); }); @@ -3152,7 +3160,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Attachment and takeover (design §10, D8) + // Attachment and takeover (D8) // ----------------------------------------------------------------------- /// **D8 in one test.** The newcomer wins, the incumbent is *told* rather @@ -3213,7 +3221,7 @@ mod tests { ); } - /// Design §10 says to close the displaced session's stream, and when the + /// The displaced session's stream is closed, and when the /// connection exists for that one workspace that is exactly right. #[test] fn a_dedicated_connection_is_closed_when_its_workspace_is_taken() { diff --git a/crates/tty7-core/src/lib.rs b/crates/tty7-core/src/lib.rs index a1354b60..a992cbd2 100644 --- a/crates/tty7-core/src/lib.rs +++ b/crates/tty7-core/src/lib.rs @@ -7,7 +7,7 @@ //! `tty7-server` must agree on byte for byte. //! //! **This crate must never depend on gpui.** That is the invariant the split -//! exists to enforce (see `docs/2026-07-27-remote-workspace-design.md` §11); +//! exists to enforce; //! `cargo tree -p tty7-core | grep gpui` must stay empty. Where a type genuinely //! needs a gpui shape — `Config` as a `Global`, `WindowState` as a `Bounds`, //! `FontFeatures` — the data lives here and the GUI crate adds the gpui-facing diff --git a/crates/tty7-server/Cargo.toml b/crates/tty7-server/Cargo.toml index 16e2ef49..11f2fa13 100644 --- a/crates/tty7-server/Cargo.toml +++ b/crates/tty7-server/Cargo.toml @@ -26,7 +26,7 @@ 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 (contract §6.4), so +# 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 diff --git a/crates/tty7-server/src/main.rs b/crates/tty7-server/src/main.rs index 65651348..4c2194e6 100644 --- a/crates/tty7-server/src/main.rs +++ b/crates/tty7-server/src/main.rs @@ -1,7 +1,7 @@ //! `tty7-server` — the tty7 session daemon with no GUI attached. //! -//! This is the binary that runs on the machine a *remote* workspace lives on -//! (see `docs/2026-07-27-remote-workspace-design.md`). It runs the same +//! This is the binary that runs on the machine a *remote* workspace lives on. +//! It runs the same //! `daemon::server` the local GUI auto-spawns, plus the control listener that //! backs a remote `Host`; the only difference from the GUI's daemon is that //! nothing on this side ever opens a window, which is why the code it needs had @@ -64,6 +64,7 @@ OPTIONS: --pane Forward to the machine's *pane* socket instead --control-sock

Use

as the control socket instead of the default --config-dir

Use for the socket, config and session files + --protocol Print the dialects this binary speaks, as JSON -V, --version Print the version and exit -h, --help Print this help and exit "; @@ -87,6 +88,22 @@ fn main() -> ExitCode { println!("tty7-server {}", env!("CARGO_PKG_VERSION")); return ExitCode::SUCCESS; } + // Before the config dir, the crash handler and the logger, like `--version`: + // a client asking what this binary speaks must not touch the machine's + // state, and must answer even on a box where the config dir is unwritable. + // + // One line of JSON on stdout, because the reader is a client parsing SSH + // output rather than a person (`install::RemoteProtocol::parse`). + if args + .iter() + .any(|a| a == tty7_core::daemon::install::PROTOCOL_FLAG) + { + println!( + "{}", + tty7_core::daemon::install::RemoteProtocol::of_this_build().to_line() + ); + return ExitCode::SUCCESS; + } if args.iter().any(|a| a == "--help" || a == "-h") { print!("{USAGE}"); return ExitCode::SUCCESS; @@ -213,7 +230,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> { // `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 - // §10's takeover a no-op between them — both clients would + // the takeover a no-op between them — both clients would // hold the same workspace and neither would be told. // // Not attempted when the caller named a socket: starting a @@ -299,8 +316,8 @@ fn bridge_panes() -> io::Result<()> { /// server, in both directions, until either side stops. /// /// Deliberately dumb: it parses nothing. The version handshake this stream -/// carries is between the *client* and the server at the far end (contract -/// §6.9), and a bridge that understood the frames would be a third opinion about +/// carries is between the *client* and the server at the far end, and a bridge +/// that understood the frames would be a third opinion about /// the protocol version, which is exactly the coupling the design forbids. #[cfg(unix)] fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { @@ -322,7 +339,7 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { // up" into a bridge that never exits and, worse, never closes its stdout, so // the client at the far end waits forever for an EOF that is sitting in this // process. Returning lets the process exit, which closes stdout, which is - // the signal the client is actually waiting for. Design §10's takeover is + // the signal the client is actually waiting for. The takeover is // the case that made this visible: the server closes the displaced session's // link, and that has to reach the client through this bridge. let feeder_socket = upstream.try_clone()?; @@ -363,7 +380,7 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { /// What this machine offers over a control connection, beyond its filesystem. /// /// The workspace store is the reason this binary exists on a remote box at all: -/// design §10 puts the workspace list, the tab/pane tree and each pane's cwd on +/// the workspace list, the tab/pane tree and each pane's cwd live on /// **the machine the panes run on**, so that connecting from a different laptop /// shows the same thing. The client's `session.json` keeps only its own view /// state. diff --git a/crates/tty7-server/tests/cli.rs b/crates/tty7-server/tests/cli.rs index ceb4eb1b..9ae58a12 100644 --- a/crates/tty7-server/tests/cli.rs +++ b/crates/tty7-server/tests/cli.rs @@ -9,7 +9,7 @@ //! //! Everything `--stdio` is Unix-only — the flag is refused on Windows, where a //! machine is reached over its own transport rather than by shipping a server -//! onto it (contract §8). The plain argument handling below is not, and runs +//! onto it. The plain argument handling below is not, and runs //! everywhere. use std::process::{Command, Stdio}; diff --git a/crates/tty7-server/tests/remote_router.rs b/crates/tty7-server/tests/remote_router.rs index 88d0358e..0825d062 100644 --- a/crates/tty7-server/tests/remote_router.rs +++ b/crates/tty7-server/tests/remote_router.rs @@ -14,7 +14,7 @@ //! unit-tested there. // Unix-only: the hub this stands up is a Unix-domain socket, which is also the -// only shape the remote side of a routed connection takes (contract §8). The +// only shape the remote side of a routed connection takes. The // Windows client reaches a *remote* server the same way; it is the local hop // that differs, and `daemon::router` covers that with its own `cfg`. #![cfg(unix)] diff --git a/crates/tty7-server/tests/routed_pane.rs b/crates/tty7-server/tests/routed_pane.rs index 1f8a3335..58d5ce46 100644 --- a/crates/tty7-server/tests/routed_pane.rs +++ b/crates/tty7-server/tests/routed_pane.rs @@ -168,6 +168,7 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { cwd: Some(dir.path().to_path_buf()), size: win(), shell: Some(plain_shell()), + owner: None, } .encode(&mut sock) .unwrap(); @@ -258,6 +259,7 @@ fn a_routed_kill_reaches_the_pane_it_names() { cwd: Some(dir.path().to_path_buf()), size: win(), shell: Some(plain_shell()), + owner: None, } .encode(&mut sock) .unwrap(); diff --git a/crates/tty7-server/tests/stdio_conformance.rs b/crates/tty7-server/tests/stdio_conformance.rs index a25004e2..2dc774d0 100644 --- a/crates/tty7-server/tests/stdio_conformance.rs +++ b/crates/tty7-server/tests/stdio_conformance.rs @@ -24,7 +24,7 @@ // Unix-only: every case here is a `--stdio` child, and `--stdio` is refused on // Windows by design — a Windows machine is reached over its own transport, not -// by shipping a server onto it (contract §8). +// by shipping a server onto it. #![cfg(unix)] use std::io; diff --git a/crates/tty7-server/tests/workspace_store.rs b/crates/tty7-server/tests/workspace_store.rs index 85661260..df750aa1 100644 --- a/crates/tty7-server/tests/workspace_store.rs +++ b/crates/tty7-server/tests/workspace_store.rs @@ -11,7 +11,7 @@ //! //! | | Why an in-process socket pair would not do | //! |---|---| -//! | The record is on **the server's** disk | The whole storage split (design §10) is "the machine is the authority". A store in the test's own address space proves nothing about that | +//! | 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 | //! @@ -387,7 +387,7 @@ fn a_change_from_one_client_reaches_the_other() { assert_eq!(store.len(), 0); } -/// **Design §10's takeover, across two real processes.** +/// **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 @@ -427,7 +427,7 @@ fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() { // 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. Design §10: "关闭它的流". + // workspace: the server closes its stream. let deadline = Instant::now() + Duration::from_secs(10); while laptop.control.is_connected() { assert!( @@ -499,7 +499,7 @@ fn bridged(sock: &Path, token: &str) -> Client { } /// [`bridged`], naming the client machine and, optionally, the workspace this -/// connection is opened *for* — the hello field design §10's takeover keys on. +/// 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, diff --git a/docs/2026-07-27-remote-workspace-design.md b/docs/2026-07-27-remote-workspace-design.md deleted file mode 100644 index bd82d634..00000000 --- a/docs/2026-07-27-remote-workspace-design.md +++ /dev/null @@ -1,430 +0,0 @@ -# tty7 远程开发:远程 Workspace 设计 - -> 状态:设计定稿,待实现 -> 日期:2026-07-27 - -## 1. 一句话 - -选一台开发机,tty7 给你一个整体就是那台机器的窗口。合上笔记本、重启、断网、换台电脑——里面的东西一直在跑,回来还在原地。 - -主卖点是 **agent 不再因为合盖而中断**。丢个 shell 忍忍就过去了,丢一个跑了四十分钟的 agent 会话不行。 - -对老用户还有第二条:**远程终于不再是残的**。今天 SSH 上去,repo 分组、分支、diff、file tree、worktree 全部消失;远程 workspace 里它们全都在。 - -## 2. 用户模型:只有一条规则 - -**一个窗口 = 一台机器的一个 workspace。** - -- 一个 remote host 可以跑多个 workspace,跟本地一样。 -- 窗口里所有 tab 和 pane 都在那台机器上,不混。 -- 同一台机器可以开好几个窗口;几台机器的窗口并排也行;本地窗口和远程窗口并排也行。 - -### 与 SSH pane 的区别 - -这是两个功能,不要混: - -| | 连一下(SSH pane) | 在上面开发(远程 workspace) | -|---|---|---| -| 干嘛 | 看个日志、重启个服务 | 写代码 | -| 单位 | 一个 pane | 一个窗口 | -| 关掉之后 | 没了 | 还在跑 | -| 入口 | 命令面板 | 首页「连接主机」 | - -远程 workspace 里不提供连别的机器的入口。用户自己在 shell 里敲 `ssh` 当然照样能用,但 tty7 不识别、不接管。 - -**机器只配一次**:远程 workspace 直接用已存的 SSH 配置(`core::ssh_profile` 的 profile、`~/.ssh/config` 的 alias),密码、密钥、跳板机全都现成。不新做一套主机配置 UI。 - -## 3. 目标与非目标 - -### v1 做 - -- Mac / Linux / Windows 都能当客户端 -- 连 Linux 机器;Windows 用户连自己的 WSL 也算 -- 服务自动装 -- 一台机器多个窗口、多台机器并存、和本地窗口混着用 -- 断线自动重连 -- repo 分组、分支、diff、worktree、agent 全套在远程可用 -- 文件浏览、端口转发 - -### v1 不做 - -- **两台电脑同时连同一个 workspace** —— 先后连没问题;撞上时是**接管**(§10),不是共享 -- **拿 Windows 当被连的机器** —— 用 WSL -- **一个窗口里既有本地又有远程** —— 这个**永远不做** -- **自动同步文件、自动猜端口** —— 手动就够了 -- 远程 workspace 里连别的机器的入口 -- 远程读远程的 `config.json`(§13) -- 断线超出 replay ring 的输出的持久化补偿(§10) -- 客户端没运行时的推送通知 - -## 4. 现状盘点 - -### 已经成立的地基 - -| 能力 | 在哪 | 对远程的意义 | -|---|---|---| -| 持久 daemon:一条连接一个 pane,`Attach`/`Detach`,断连即 detach、pane 继续裸跑 | `daemon/server.rs`、`daemon/pane.rs` | "合上笔记本还在跑"在本地已经成立,远程要做的是把这个 daemon 挪到对面 | -| `ReplayRing`:断连期间的输出进环形缓冲,attach 时重放 | `daemon/pane.rs` | 重连补屏直接可用 | -| `PROTOCOL_VERSION` 握手 + 不兼容时询问用户 | `daemon/spawn.rs::ensure_running` | 远程版本 skew 照搬 | -| 传输抽象:`Stream = Read + Write + try_clone` | `daemon/transport.rs` | 多一种传输形态不破坏上层 | -| 原生 SSH 栈:连接复用(`ConnectionKey`)、`direct-tcpip`、session channel、SFTP、known_hosts、auth broker、jump / ProxyCommand / SOCKS5 | `daemon/ssh/*` | 远程 workspace 的传输层几乎白送 | -| Workspace 模型:`Workspace` = 一组 tab + 窗口几何 + `open` 标记 + 名字,home 页 picker | `core/session.rs` | 远程模型 1:1 照搬,不新造概念 | -| agent 状态:hook 发 OSC 777 → daemon 侧 sniffer → 客户端 | `core/agent_hooks.rs`、`daemon/pane.rs` | PTY 在哪 sniffer 就在哪,远程天然成立 | - -### 反着的那块 - -右侧那套富功能全部直接读 **GUI 进程自己的**文件系统和 git: - -- `ui/file_tree.rs:120` —— `std::fs::read_dir`,注释明写 "no daemon round-trips (the SFTP panel covers the remote case)" -- `ui/app.rs:3895`、`core/worktree.rs:65` —— `Command::new("git")`,跑在客户端 -- gitignore 判定、`notify` 文件监听、repo 根上溯(`ui/file_tree.rs:628` 的 `.find(|p| p.join(".git").exists())`)也都是本地 fs - -所以"全套在远程可用"不是把 daemon 挪过去就顺带有的,它是本设计里最大的一块(§8)。 - -## 5. 关键决策一览 - -| # | 决策 | 选了 | 否掉了 | 为什么 | -|---|---|---|---|---| -| D1 | 富功能远端化 | 抽 `Host` 抽象层,本地直调 / 远程 RPC | 窄推送 + 复用 SFTP 面板 | 窄方案下 file tree 没有 gitignore 和文件监听,diff overlay 和 code editor 在远程缺失或另写一套,长期两条代码路径并存,最后还得推倒 | -| D2 | 远程二进制 | 拆出 headless crate,远程只装 `tty7-server` | 远程跑完整 `tty7 --daemon`;同 crate 加 cargo feature | 完整二进制含 gpui/字体/资源且在无头 Linux 上可能因缺 libfontconfig 起不来,而无头机正是目标场景;cargo feature 方案会让 `#[cfg]` 撒遍 `ui/` 和 `core/` | -| D3 | 谁开 SSH 连接 | 本地 daemon 当转发中枢,GUI 传输层不动 | GUI 内嵌 russh 直连 | SSH 引擎、auth broker、known_hosts、jump 链、端口转发全在本地 daemon;GUI 直连意味着两套 SSH 引擎并存 | -| D4 | 通道形态 | 每条逻辑流一条 SSH channel,首选 `direct-streamlocal` | 自建多路复用层 | russh 客户端侧有 `channel_open_direct_streamlocal`(`client/mod.rs:854`),远程零 bridge 进程、零 mux 代码 | -| D5 | 二进制怎么上去 | 客户端下载 + SFTP 上传 | 远程 curl;两者都做并回退 | 内网 / 跳板机后面的机器上不了外网,而那是一大类目标用户;双路径的失败回退边界很难调对 | -| D6 | 断线时的窗口 | 只读降级 + 状态条 | 整窗遮罩;缓存输入重连后发 | 断线那一刻最想看的就是 agent 断之前输出了什么;缓存输入会在看不见的时候落到一个已经变样的屏幕上 | -| D7 | 启动时 | 即连,认证 sheet 排队一次弹一个 | 开窗不连等点击;按凭证类型分情况 | "回来还在原地"不该变成"回来再点一下";按凭证分情况会让同一个动作在不同机器上行为不同 | -| D8 | 两个客户端撞上 | 后来者接管,先来的转 `Preempted` 只读 | 拒绝后来者;并存只读旁观 | 最常见的撞车是"旧机器忘了关",拒绝等于把人锁在门外;并存只读实质就是在做多客户端 | -| D9 | WSL | 单独一条 stdio 传输 | 要求 WSL 里跑 sshd;v1 不做 WSL | 为一个本机上的发行版配 sshd 很荒谬,也拆了"服务自动装"的台;stdio 传输还顺带让端到端测试不需要 sshd(§17) | -| D10 | Linux 二进制链接方式 | musl 静态链接 | glibc 动态链接 | 一个二进制通吃所有发行版,不看目标机的 glibc 版本 | - -## 6. 架构总览 - -``` -┌─ 客户端 GUI(gpui) ─────────────────────────────┐ -│ TerminalView×N file_tree / git_diff / │ -│ │ worktree / code_editor │ -│ │ │ │ -│ │ Host trait ◄── 新 │ -│ ▼ ▼ │ -│ RemoteTerminal(现有) LocalHost │ RemoteHost │ -└────────┴────────────────────┴────────────────────┘ - │ 现有 transport:UDS / loopback TCP,不动 - ▼ -┌─ 本地 daemon ────────────────────────────────────┐ -│ 本地 pane(PTY)· SSH pane · SFTP · 端口转发 │ -│ SshManager / PromptBroker / known_hosts / jump │ -│ ── 全部现有,远程 workspace 直接复用 ── │ -│ RemoteRouter ◄── 新:纯字节转发,不解析 │ -└────────┬─────────────────────────────────────────┘ - │ SSH:每条流一条 channel - │ 首选 direct-streamlocal → 远程 daemon.sock - │ 回退 session channel + exec tty7-server --stdio - │ WSL:wsl.exe 子进程的 stdin/stdout - ▼ -┌─ 远程 tty7-server(headless,无 gpui) ──────────┐ -│ pane registry(DaemonPane,现有代码原样搬) │ -│ workspace store ◄── 新:布局的权威副本存这里 │ -│ Host 服务端 ◄── 新:fs / git / watch RPC │ -└──────────────────────────────────────────────────┘ -``` - -**GUI 侧传输代码一行不改**。`transport::Stream` 仍然是那条本地流;`Spawn` / `Attach` / control 消息多带一个路由头,说明"去哪台机器"。本地 daemon 对远程流只做字节转发,不解析内容。 - -## 7. 传输层 - -### 7.1 SSH 主机 - -每条逻辑流一条 SSH channel,不自建多路复用: - -| 流 | channel 数 | 说明 | -|---|---|---| -| 每个 pane | 1 | 对应现有"一条连接 = 一个 pane" | -| 每个远程 workspace 的控制流 | 1 | Host RPC + workspace store + 事件推送 | - -**首选** `direct-streamlocal@openssh.com` 直接接到远程的 daemon socket。OpenSSH 的 `AllowStreamLocalForwarding` 默认为 `yes`。 - -**回退**:被管理员关掉时(channel open 失败),改用 session channel `exec tty7-server --stdio`——一个纯字节转发的小进程,把自己的 stdin/stdout 接到同一个 unix socket。回退是每连接一次性探测,结果缓存在 `SshConnection` 上,不逐 channel 重试。 - -同一台机器的多个 workspace 共用一条 `SshConnection`(现有 `ConnectionKey` 的复用逻辑直接生效):一台机器只认证一次。 - -### 7.2 远程 socket 路径 - -`$XDG_RUNTIME_DIR/tty7/daemon.sock`,没有 `XDG_RUNTIME_DIR` 时退到 `~/.local/share/tty7/daemon.sock`。`sun_path` 长度限制的 fallback(短路径 + 配置目录哈希)沿用 `transport.rs` 现有实现。 - -一台机器**一个** `tty7-server`(per user),多个 workspace 在它内部;socket 权限 0600,目录 0700。 - -### 7.3 WSL - -`wsl.exe -d -- tty7-server --stdio`,子进程的 stdin/stdout 就是 `Stream`。无 SSH、无认证、无网络。 - -## 8. 协议扩展 - -现有协议是"一条连接一个 pane",控制类只有短连接 `List`。Host 层要的是长连接上的请求/响应,量大且并发。 - -新增一条 **control 连接**,`PROTOCOL_VERSION` bump 到 **3**。 - -### 帧格式 - -沿用外层 `[u32 LE payload_len][u8 kind][payload]`,control 连接的 kind 是新值: - -| 形态 | payload 布局 | 用于 | -|---|---|---| -| 小请求 / 响应 | `[u64 req_id][JSON]` | `read_dir`、`stat`、`git`、`repo_root`、workspace 读写 | -| 大 payload | `[u64 req_id][raw bytes]` | `read_file` / `write_file` 的文件内容 | -| 事件推送 | `[u64 req_id = 0][JSON]` | 文件变更、pane 死亡、agent 状态、被接管通知 | - -`req_id` 允许乱序匹配,所以一个慢的 `git` 调用不会堵住 file tree 的目录展开。`req_id == 0` 保留给无请求对应的服务端推送。 - -热路径(pane 的 `Input` / `Output` / `Snapshot`)不走 control 连接,保持现有的零序列化直传。 - -## 9. Host 抽象层 - -```rust -pub trait Host: Send + Sync { - fn read_dir(&self, p: &Path) -> io::Result>; // Entry 带 ignored 标记 - fn stat(&self, p: &Path) -> io::Result; // 含 mtime - fn read_file(&self, p: &Path) -> io::Result>; - fn write_file(&self, p: &Path, b: &[u8]) -> io::Result<()>; - fn create_dir(&self, p: &Path) -> io::Result<()>; - fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; - fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>; - fn repo_root(&self, p: &Path) -> io::Result>; - fn git(&self, cwd: &Path, args: &[&str]) -> io::Result; - fn watch(&self, dirs: &[PathBuf]) -> WatchSub; -} -``` - -**同步阻塞签名是刻意的。** 这些调用点现在全部已经在 background executor 上跑(`file_tree.rs` 的注释:render 只读缓存,miss 变成排队加载),保持阻塞语义意味着调用点的结构一行不用动,只换实现来源。 - -`LocalHost` 直调 `std::fs` / `Command::new("git")`,零开销、零往返。`RemoteHost` 走 control 连接的 RPC。 - -### 三个为"每次往返都要钱"而变形的方法 - -| 方法 | 天真做法的问题 | 设计 | -|---|---|---| -| `read_dir` 的 `ignored` | 客户端自己解析 `.gitignore` 链,一次展开要往返读好几个 `.gitignore` | **服务端算好再返回**。gitignore 解析代码搬进 `tty7-core`,本地与远程共用同一份,一个目录一次往返 | -| `repo_root` | 现在是逐级 `p.join(".git").exists()` 上溯,远程等于逐级往返 | 提成一个方法,服务端一次走完 | -| `watch` | 递归 watch 一个大 repo,事件洪水跨网络 | 只 watch **已展开的目录集合**,非递归;服务端按 100ms 窗口合并后批量推送 | - -### `git` 的约定 - -`GIT_OPTIONAL_LOCKS=0` 的只读约定(现在在 `git_status::git` helper 里)下沉到 `Host::git` 的两个实现里,两边一致。远程一次 git 探针 = 一次往返,跨洲可能 200ms+;这是可接受的,因为探针本来就是后台触发(cd / 命令结束 / agent 回合结束),UI 期间显示上一份快照。 - -### Host 从哪来 - -一个 workspace 一个 `Arc`,pane 和面板从所属 workspace 拿。本地 workspace 拿 `LocalHost`。 - -### 要改的调用点 - -| 文件 | 内容 | -|---|---| -| `ui/file_tree.rs` | `read_dir`、gitignore 判定、`notify` watcher、新建 / 重命名 / 删除、`:628` 的 repo root 上溯 | -| `ui/code_editor.rs` | `:343` `:382` `:642` 的 stat、`:531` 的 write、`:691` 的 read;mtime 冲突检测照旧,走 `Host::stat` | -| `terminal/git_status.rs` | 分支 + `+N −M` 的 shell-out;`GitStatusCache` 的 key 从 `PathBuf` 变成 `(HostId, PathBuf)` | -| `terminal/git_diff.rs` | `git diff HEAD`。`ui/diff_overlay.rs` 只消费结果,本身不用改 | -| `core/worktree.rs` | `git worktree add` / `list`、`is_inside_repo`、`.tty7/.gitignore` 的写入。路径构造是纯字符串,留在客户端 | -| `ui/app.rs:3895` | 送给 agent 的 diff。这里现有一道显式挡板(`local_cwd()`,注释:"远程 pane 的 cwd 不能用本地 git")——改造后这道挡板拆掉,远程 pane 的 diff 真的能取到 | - -## 10. 会话与 workspace 模型 - -### 存储分工 - -| 存在哪 | 内容 | 为什么在这边 | -|---|---|---| -| **远程** `~/.local/share/tty7/workspaces.json` | workspace 列表与名字、tab / pane 树、每个 pane 的 cwd / pane_id / agent 信息、`last_active` | 换台电脑连过来要看到同一份。这是机器的事实 | -| **客户端** `session.json` | 「我连过哪些 host 的哪些 workspace」、窗口几何、`open` 标记 | 这是**这台客户端**的视图状态。公司电脑上关掉窗口,不该让家里电脑看不见 | - -`Workspace` 加一个字段: - -```rust -pub struct Workspace { - // ...现有字段不动 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host: Option, // None = 本地,语义与今天完全一致 -} -``` - -远程条目的 `session` 字段在客户端留空——布局的权威在远程,连上之后拉。旧的 `session.json` 没有 `host` 字段,反序列化后全是 `None`,即全部是本地 workspace,与今天行为逐字相同。 - -`RemoteRef` 指向一个已存的 SSH 配置(profile id 或 `~/.ssh/config` alias 或 `user@host:port`)加上远程侧的 `WorkspaceId`。 - -**`HostId`** 是客户端进程内对一个 `Arc` 的稳定标识:本地是一个固定值,远程由 `RemoteRef` 里的连接部分派生(同一台机器的多个 workspace 共享同一个 `HostId`,与 §7.1 的连接复用同粒度)。它只在进程内有效,不持久化。 - -pane 标识在客户端侧是 `(HostId, pane_id)`;`pane_id` 只在单台远程 server 内唯一。 - -### 首页入口 - -「连接主机」→ 选一个已存的 SSH 配置 → 连上(首次触发安装,§12)→ 列出这台机器上已有的 workspace + 「新建」。新建的 workspace 落在 `~`,名字按现有 `Workspace::display_name` 的规则从 tab 的 repo / cwd 推导。 - -### 连接状态机 - -``` -Disconnected ──connect──> Connecting ──✓──> Attached - │ ✗ - ▼ - Failed(状态条给 [重试]) - -Attached ──网络断──> Reconnecting ──✓──> Attached - 只读 + 状态条,指数退避 1/2/4/…/30s 封顶,无限重试 - -Attached ──别处 attach──> Preempted - 只读 + [抢回],不自动重连 -``` - -**永不自动关窗**,任何失败态都停在窗口里等用户处置。 - -**只读降级的具体表现**:窗口照常显示,能滚历史、能选能复制、能 ⌘F 搜索;键盘输入不生效,底部一条"未连接 — 输入暂不生效",顶部一条状态条写当前状态。输入**不缓存**(见 D6)。 - -**重连流程**:control 连接重建 → 拉 workspace 布局 → 对每个 pane 重开 channel + `Attach` + replay 补屏 → 以新客户端的尺寸 `Resize`。 - -**补屏的诚实边界**:断得太久、输出太多,`ReplayRing`(默认几 MB)会滚掉最早的部分,那时以 daemon 当前的 grid 快照为准,中间那段是真的丢了。这与今天本地 daemon 的行为一致,不额外承诺。 - -### 接管 - -远程 server 为每个 workspace 记录当前 attach 的客户端会话(一个随机 token + 客户端主机名)。新的 attach 到来时,向旧会话推送 `Preempted { by: <主机名> }` 然后关闭它的流。旧客户端转入 `Preempted` 状态,状态条写"已在 <主机名> 上打开",给一个 [抢回] 按钮——点了就是反向再接管一次。 - -### 启动时 - -`open: true` 的远程 workspace 在启动时立即重开并连接。需要认证的窗口**一次只弹一个 sheet**,其余排队;不需要认证的(密钥、ssh-agent)并行连。 - -## 11. crate 拆分 - -`src/daemon/` 依赖的 core 模块只有 7 个:`agent_hooks` `cli_agent` `config` `osc` `proc` `shells` `threads`。其中真正沾 gpui 的**只有 `config` 一个文件的一行** `use gpui::{FontFeatures, Global}`(`cli_agent` 的两处 gpui 只是注释)。 - -| 搬进 `tty7-core` | 处理方式 | -|---|---| -| `daemon/*`(protocol、pane、server、ssh、transport、shell_integration…) | 原样搬,零改动 | -| `core/{osc, proc, shells, threads, agent_hooks, cli_agent}` | 原样搬 | -| `core/config` | `font_features` 在 core 里存 `HashMap`,GUI 侧转 `gpui::FontFeatures`;`impl Global` 留在 GUI | -| `core/session` 的数据部分(`SessionPane` / `SessionTab` / `Workspace` / `Workspaces`) | 纯 serde,搬。`WorkspaceStore`(gpui `Global` + `claim` / `focus` / `rename`)留在 GUI | -| `core/worktree`、gitignore 解析、`git_status` 的 shell-out helper | 搬——服务端要用同一份 | -| `core/crash` | 搬(远程 server 崩了也要写 crash.log) | -| 留在 GUI crate | `ui/*`、`terminal/*`、`core/{actions, window_state, update}` | - -产物: - -``` -tty7-core 无 gpui,protocol / daemon / pty / ssh / Host 的两个实现 / 服务端 RPC -tty7 GUI bin,依赖 gpui + tty7-core -tty7-server headless bin,只依赖 tty7-core -``` - -CI 增加 `x86_64-unknown-linux-musl` 和 `aarch64-unknown-linux-musl` 两个 target,产出 `tty7-server` 的静态二进制(D10)。 - -## 12. 安装、启动、版本 - -``` -1. uname -sm → Linux x86_64 -2. SFTP stat ~/.local/share/tty7/bin/tty7-server-<客户端版本> -3. 不在 → 客户端 GET GitHub Release asset + sha256 校验 -4. SFTP put → bin/.tty7-server-.tmp -5. chmod 0755 → rename(原子) -6. direct-streamlocal 试连远程 daemon socket - 连不上 → exec 一次 tty7-server --daemon(setsid 脱离)→ 重试 -``` - -第 6 步就是远程版的 `spawn::ensure_running`。 - -**首次连一台新机器时,安装那一步给一次明确确认**(写哪个路径、多大、从哪来),之后同一台机器的升级静默。往别人机器上写二进制值得问一次。 - -**全程不用 sudo**,只碰 `$HOME`。 - -**版本不匹配**照搬 `spawn::ensure_running`:握手比 `PROTOCOL_VERSION`,兼容就继续用旧 server;不兼容就问用户"保留旧会话(继续用旧方言)还是重启服务(丢掉正在跑的 pane)"。二进制路径带版本号所以能并存,但 socket 只有一个——并存的是文件,不是运行中的服务。 - -**WSL 的安装**不走下载:直接把客户端自带的 Linux 二进制拷到 `\\wsl$\\home\\.local\share\tty7\bin\`。这要求 Windows 客户端的安装包里带一份 `tty7-server` 的 Linux musl 二进制。 - -## 13. 配置归属 - -**客户端的 `config.json` 是唯一权威,远程机器上不需要 config.json。** - -服务端需要知道的字段(`shell`、`shell_args`、`agent_commands`、`restore_agent_sessions`)随 `Spawn` / 控制消息下发。现有 `ShellSpec` 已经是这个做法,照着扩。 - -远程 workspace 窗口里的 Settings 页显示、修改的都是客户端配置,与本地窗口无差别。 - -## 14. agent 集成 - -链路在远程与本地同构,只换了位置: - -``` -远程 agent 进程 - └─ hook 调 tty7-server agent-hook - └─ 写 OSC 777 到控制终端 - └─ 远程 server 的 sniffer 收进 pane 状态 - └─ control 连接推送到客户端 - └─ tab 状态点 / 通知 / tray 图标 -``` - -要改的两处: - -- hook emitter 的命令从 `tty7 agent-hook` 变成远程的 `tty7-server agent-hook`(同一份代码,换 bin)。 -- `TTY7` env marker 由远程 server 在 spawn 时注入(现有逻辑原样搬)。 - -Settings → Agents 的"安装 hooks"动作,在远程 workspace 下作用于**远程机器**(走 `Host::write_file`)。 - -## 15. 端口转发与文件传输 - -### 端口转发 - -远程 workspace 下,转发的归属从 **pane 变成 workspace**(现有 `SshForwardRegistry` 按 `pane_id` 键,要加一个 workspace 维度)。转发跑在该 workspace 所属的 `SshConnection` 上,现有 `daemon/ssh/forward.rs` 直接可用。 - -⌘/Ctrl-click 远程 pane 里的 `localhost:PORT`:在该 workspace 的连接上按需建一条 local forward,再用本地浏览器打开。这是**按需**,不是自动扫描——"自动猜端口"不做。 - -**WSL 例外**:WSL 与 Windows 共享 localhost,不需要任何转发,⌘-click 直接开浏览器。 - -### 文件传输 - -| 场景 | 走哪 | -|---|---| -| file tree 浏览、打开、保存、新建 / 重命名 / 删除 | `Host`(统一,走 control 连接) | -| 大文件上传 / 下载、拖到 Finder | 现有 SFTP 面板,同一条 SSH 连接 | -| WSL 的大文件传输 | 没有 SFTP;走 `Host::read_file` / `write_file`,或直接用 `\\wsl$` 路径 | - -## 16. 安全 - -| 面 | 措施 | -|---|---| -| 二进制来源 | GitHub Release + sha256 校验(release 里带 checksums 文件),校验失败即中止,不装 | -| 权限范围 | 不用 sudo,只写 `$HOME`;目录 0700,socket 0600 | -| 通道信任边界 | `direct-streamlocal` 只有已认证的 SSH 会话能开,等价于 SSH 本身的信任边界。tty7 自身的通信**不开任何监听端口**(用户显式要求的端口转发是另一回事,见 §15) | -| 主机认证 | 沿用现有 known_hosts(新主机 / 变更主机的 GUI 确认 sheet) | -| 首次写入的知情 | 首次安装给一次明确确认(§12) | - -## 17. 错误处理与降级 - -| 情况 | 行为 | -|---|---| -| 远程没装 git | `Host::git` 返回错误;分支 / diff / worktree 优雅缺省(跟本地非 repo 目录同路径),file tree 照常工作,`ignored` 全为 false | -| `AllowStreamLocalForwarding no` | 自动回退 stdio bridge(§7.1),用户无感 | -| 远程磁盘满 / 无写权限 | 安装报明确错误(路径 + 原因),不重试,不降级到别的路径 | -| 远程 server 崩了 | 客户端的 pane 流全断 → 走 `Reconnecting`;重连时 `ensure_running` 把它拉起来。**布局不丢**(远程的 `workspaces.json` 是持久化的),但 pane 进程没了,按现有"pane 不存在"的路径处理:依 `workspaces.json` 里的 cwd / agent 信息重新 spawn,agent 走现有的 `--resume` 恢复 | -| control 连接断但 pane 流还活着 | 不允许——control 连接是 workspace 的生命线,它断了就整个 workspace 转 `Reconnecting` | -| 单个 `Host` RPC 超时 | 该请求返回 `TimedOut`,调用点显示上一份缓存 / 加载态,不影响其它请求(`req_id` 乱序匹配) | -| sha256 不匹配 | 中止安装并明确报出来,不静默重试、不降级到无校验安装 | - -## 18. 验证策略 - -最重要的一条:**stdio 传输让远程 workspace 能在 CI 里端到端测,不需要 sshd、不需要网络**——同机起一个 `tty7-server --stdio` 子进程,跑完整的"远程" workspace 流程。 - -| 层 | 怎么测 | -|---|---| -| `Host` trait | 一套 conformance 测试,`LocalHost` 和 `RemoteHost` 都跑,逐条比对结果 | -| 协议 | round-trip(照搬 `protocol.rs` 现有模式)+ 版本 skew 的握手分支 | -| 传输 | streamlocal 与 stdio 回退各一个集成测试 | -| 状态机 | 重连退避、接管、启动排队认证——纯单元测试,不碰网络 | -| 安装 | `uname` 解析、版本路径构造、原子替换、sha256 失败路径 | -| 端到端 | stdio 传输跑通"开 workspace → 开 pane → 断开 → 重连补屏 → 接管" | -| 回归护栏 | M1 / M2 是纯重构,现有全部测试必须逐条绿,不允许改测试来适配 | - -## 19. 里程碑 - -前两步是**纯重构、零行为变化、CI 必须全绿**——这让这份大 spec 有一段安全的前半程。 - -| | 内容 | 完成标志 | -|---|---|---| -| M1 | crate 拆分(§11) | 本地功能一个不少,`tty7-server` 能在无头 Linux 上跑起来 | -| M2 | `Host` trait + `LocalHost`,改造全部调用点(§9) | 行为逐字不变 | -| M3 | control 连接 + Host 服务端 RPC(§8) | stdio 传输在本机端到端跑通 | -| M4 | SSH 传输 + 安装 + 版本协商(§7.1、§12) | 能连一台真的远程机器 | -| M5 | workspace 模型 + 首页入口 + 窗口绑定(§10) | 一台机器多窗口、多机器并存、和本地混开 | -| M6 | 状态机:重连 / 接管 / 启动即连(§10) | 拔网线再插回来 | -| M7 | 端口转发 + SFTP 在远程 workspace 下接线(§15) | 远程起的 dev server,⌘-click `localhost:3000` 能在本地浏览器打开;拖文件到 Finder 能下来 | -| M8 | WSL(§7.3、§12) | Windows 上「连接主机」能选到本机 WSL 发行版,全套功能与 SSH 主机一致 | diff --git a/docs/2026-07-27-remote-workspace-impl-contract.md b/docs/2026-07-27-remote-workspace-impl-contract.md deleted file mode 100644 index f10d14f6..00000000 --- a/docs/2026-07-27-remote-workspace-impl-contract.md +++ /dev/null @@ -1,1380 +0,0 @@ -# 远程 Workspace:实施契约 - -> 状态:契约定稿,供并行实现 -> 日期:2026-07-27 -> 配套:[`2026-07-27-remote-workspace-design.md`](./2026-07-27-remote-workspace-design.md)(下称"设计文档") - -## 0. 这份文档是什么 - -设计文档定方向,**这份定接口**。签名、字节布局、kind 数值、文件归属、测试清单 —— 都是可以直接抄进代码的形态。 - -**读法**: - -| 你是 | 先读 | -|---|---| -| 任何 agent | §1 岔路口裁决、§9 模块归属、§11 设计文档勘误 | -| A1(crate 拆分) | §9、§11 的 11/12/14/15 条 | -| A2(Host + LocalHost) | §2 §3 §4 §10 | -| A3(调用点改造) | §1 §2 §5 §11 的 2/3/9/10/13 条 | -| A4(control wire + RemoteHost) | §6 §7 §10 | -| A5(control server + tty7-server) | §6 §7 §8 | - -**冲突仲裁**:本文档与设计文档冲突时,**以本文档为准**;§11 逐条记录了偏差与理由。 - -**行号约定**:正文里 `src/daemon/…` / `src/core/…` 形式的行号引用取自 **M1 拆分前**的树。A1 已经把它们搬到 `crates/tty7-core/src/{daemon,core}/…`(模块路径刻意保持不变,见 §9.1),行号大体不动。`src/ui/…` / `src/terminal/…` 的引用仍然有效。 - ---- - -## 1. 岔路口裁决:阻塞 vs 异步 - -### 裁决 - -**`Host` trait 保持同步阻塞签名。所有调用点一律搬到 background executor,不做例外。** - -### 为什么不做 async trait - -| 理由 | 展开 | -|---|---| -| **object safety** | 全树需要 `Arc`(设计文档 §9"一个 workspace 一个 `Arc`")。`async fn` in trait 目前不 object-safe,只能 `#[async_trait]` 装箱,`LocalHost` 每次 `stat` 都要一次堆分配 —— 而 `LocalHost` 是 99% 的路径 | -| **服务端复用** | `tty7-server` 侧的 RPC handler 跑在线程池上,本来就是阻塞的。同一个 `LocalHost` 实例既服务本地 GUI 又服务远程客户端,只有阻塞签名能一份代码两处用 | -| **传染性** | GPUI 的 `&mut Context` 不能跨 `.await` 持有。把 Host 变 async 不会减少调用点改造量 —— 该拆的还是要拆,只是多背一个 async runtime | -| **既有先例** | `terminal/view.rs:3963-3980` 的远程路径补全已经是这个形状:`cx.spawn` → `cx.background_spawn(阻塞调用)` → `this.update` 落地。它工作良好,照抄即可 | - -### 但设计文档 §9 那句话是错的,必须正面处理 - -> 设计文档 §9:"这些调用点现在**全部已经**在 background executor 上跑……保持阻塞语义意味着调用点的结构一行不用动。" - -**实测不成立。** 已经在 background 上的只有 `file_tree` 的 `read_dir` + gitignore 编译(`request_load` :448-479)。以下全部在 **UI 线程同步跑**: - -| 文件 | 行 | 调用 | 所在函数 | -|---|---|---|---| -| `ui/code_editor.rs` | 328 | `path.canonicalize()` | `open_file_in_editor` | -| `ui/code_editor.rs` | 343 | `std::fs::metadata` | `open_file_in_editor` | -| `ui/code_editor.rs` | 361 | `std::fs::read` | `open_file_in_editor` | -| `ui/code_editor.rs` | 382 | `std::fs::metadata` | `open_file_in_editor` | -| `ui/code_editor.rs` | 531 | `std::fs::write` | `editor_save_active` | -| `ui/code_editor.rs` | 535 | `std::fs::metadata` | `editor_save_active` | -| `ui/code_editor.rs` | 642 | `std::fs::metadata` | `editor_handle_external_change` | -| `ui/code_editor.rs` | 691 | `std::fs::read_to_string` | `editor_reload_from_disk` | -| `ui/code_editor.rs` | 697 | `std::fs::metadata` | `editor_reload_from_disk` | -| `ui/file_tree.rs` | 953 | `File::create_new` | `file_tree_commit_edit` | -| `ui/file_tree.rs` | 957 | `fs::create_dir` | `file_tree_commit_edit` | -| `ui/file_tree.rs` | 963 | `to.exists()` | `file_tree_commit_edit` | -| `ui/file_tree.rs` | 967 | `fs::rename` | `file_tree_commit_edit` | -| `ui/file_tree.rs` | 998 | `path.is_dir()` | `file_tree_delete` | -| `ui/file_tree.rs` | 1013/1015 | `remove_dir_all` / `remove_file` | `file_tree_delete` | -| `ui/app.rs` | 3997-4005 | `Command::new("git")` ×2 | `send_git_diff_to_agent` | -| `core/worktree.rs` | 62-73 | `Command::new("git")` | `git` helper(同步调用方待查) | - -**后果**:M2 **不是**"只换实现来源"。M2 包含一次真实的异步化改造,且它**不是零行为变化**(见下表)。里程碑表 §19 说"M1 / M2 是纯重构、零行为变化"—— M1 是,M2 不是。这个预期要在 M2 开工前对齐。 - -### 允许的行为变化(M2 唯一豁免清单) - -| 调用点 | 改造前 | 改造后 | 用户可见差异 | -|---|---|---|---| -| `open_file_in_editor` | 同步打开 | tab 立刻出现,内容异步填 | 大文件/远程时先看到空 tab + loading | -| `editor_save_active` | 同步落盘 | 异步落盘,落地前 buffer 标 `saving` | ⌘S 后 dirty 标记延迟一帧清除 | -| `file_tree_commit_edit` | 同步创建/改名 | 异步,乐观插入行 | 失败时行会消失 + 通知 | -| `file_tree_delete` | 同步删除 | 异步,乐观移除行 | 同上 | -| `send_git_diff_to_agent` | 同步 `git diff` | 异步 | 大 repo 上不再卡 UI(改善) | - -**除此之外任何行为变化都是 bug。** - -### 强制模式:`HostOps` - -GUI 侧禁止直接 `host.stat(...)`。一律走 `ui/host_ops.rs` 的门面(§5)。理由: -- 单一出口便于加 in-flight 去重、staleness 校验、错误通知 -- `debug_assert` 守卫能集中在一处,把"谁又在 UI 线程上阻塞了"变成一个 panic 而不是一次卡顿 - ---- - -## 2. `Host` trait 最终签名 - -**位置**:`crates/tty7-core/src/host/mod.rs` -**负责人**:A2 - -```rust -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -// --------------------------------------------------------------------------- -// 标识 -// --------------------------------------------------------------------------- - -/// 客户端进程内对一个 `Arc` 的稳定标识。**不持久化**,只做 -/// 进程内 key(`GitStatusCache`、pane 标识、in-flight 表)。 -/// -/// - `HostId::LOCAL` 是常量 0,本地 host 永远是它。 -/// - 远程由 `RemoteRef` 的**连接部分**(不含 `WorkspaceId`)派生:同一台机器的 -/// 多个 workspace 共享一个 `HostId`,与 §7.1 的 `SshConnection` 复用同粒度。 -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] -pub struct HostId(pub u64); - -impl HostId { - pub const LOCAL: HostId = HostId(0); - - /// 从连接标识派生。`key` 必须是规范化后的连接串(见 §4.2), - /// 相同机器必须产出相同串。碰撞到 0 时强制搬到 1 —— 0 是本地的保留值。 - pub fn from_connection_key(key: &str) -> HostId { - let h = fnv1a64(key.as_bytes()); - HostId(if h == 0 { 1 } else { h }) - } - - pub fn is_local(self) -> bool { - self == HostId::LOCAL - } -} - -/// 与 `transport::socket_path_for` 同款 FNV-1a:跨编译器/std 版本稳定。 -/// A1 把它从 `daemon/transport.rs` 提到 `tty7-core` 的公共位置,两处共用一份。 -pub fn fnv1a64(bytes: &[u8]) -> u64 { /* 照搬 transport.rs:59-66 */ } - -// --------------------------------------------------------------------------- -// 辅助类型 -// --------------------------------------------------------------------------- - -/// 一个目录项。**没有 `path` 字段** —— 路径由调用方 `Host::join(dir, &e.name)` -/// 重建。原因:远程路径的分隔符是远端的,`PathBuf::join` 在 Windows 客户端上 -/// 会吐出 `/home/me\src`(见 §4.3)。 -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Entry { - pub name: String, - pub is_dir: bool, - /// 符号链接本身是否是 link。`is_dir` 已经**跟随**链接解析过 - /// (与 `SftpEntry.target_is_dir` 的语义一致)。 - pub is_symlink: bool, - /// 服务端算好的 gitignore 判定(含 `.git` 本身恒为 true)。 - /// 目录不在任何 repo 里,或远程没装 git 时,恒为 `false`。 - pub ignored: bool, -} - -/// 文件元信息。刻意不是 `std::fs::Metadata`(不可构造、不可序列化)。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Meta { - pub is_dir: bool, - pub is_symlink: bool, - pub len: u64, - /// `None` = 平台/文件系统没有 mtime。 - pub mtime: Option, - pub readonly: bool, -} - -/// 精确到纳秒的 mtime。**不用毫秒**:`code_editor` 的外部变更检测靠 -/// mtime 相等判断"这是我们自己刚写的回声",毫秒粒度会把同毫秒内的真实外部 -/// 修改吞掉。**不用 `u128` 纳秒**:JSON 数字放不下 u128 而不失精度。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] -pub struct MTime { - /// Unix epoch 起的整秒,可为负(1970 之前)。 - pub secs: i64, - /// 0..1_000_000_000。 - pub nanos: u32, -} - -/// 一次子进程执行的结果。刻意不是 `std::process::Output` -/// (`ExitStatus` 不可跨平台构造)。 -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct Output { - /// `None` = 被信号杀死 / 无法取得退出码。 - pub status: Option, - pub stdout: Vec, - pub stderr: Vec, -} - -impl Output { - pub fn success(&self) -> bool { self.status == Some(0) } - /// stdout 按 UTF-8 lossy 解读并 trim —— 三个既有 git helper 的公共形状。 - pub fn stdout_trimmed(&self) -> String { - String::from_utf8_lossy(&self.stdout).trim().to_string() - } - pub fn stderr_trimmed(&self) -> String { - String::from_utf8_lossy(&self.stderr).trim().to_string() - } -} - -/// 一个**长命**的文件监听订阅。dir 集合可变 —— file tree 每次展开都会改动 -/// 集合,重建整个订阅代价太大(远程等于一次往返 + 服务端重建 notify)。 -/// -/// Drop 即退订(远程侧发 `watch.close`)。 -pub struct WatchSub { - rx: smol::channel::Receiver>, - inner: Box, -} - -impl WatchSub { - /// 批量事件流。服务端已按 100ms 窗口合并去重(§6.6);本地实现同样合并, - /// 两边行为一致。 - pub fn events(&self) -> &smol::channel::Receiver> { &self.rx } - - /// 替换监听的目录集合。差量由实现自己算,调用方只给全集。 - /// 一律**非递归**。 - pub fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()> { self.inner.set_dirs(dirs) } -} - -pub trait WatchHandle: Send + Sync { - fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()>; -} - -// --------------------------------------------------------------------------- -// Host -// --------------------------------------------------------------------------- - -/// 一台机器的文件系统 + git 出口。 -/// -/// **所有方法都是阻塞的,且禁止在 UI 线程上调用**(§1)。GUI 侧一律经 -/// `ui::host_ops::HostOps`,它负责 `background_spawn` 与落地。 -/// -/// object-safe 是硬要求:全树持 `Arc`。 -pub trait Host: Send + Sync + 'static { - // ----- 标识 ------------------------------------------------------------ - - fn id(&self) -> HostId; - - /// 该 host 的路径分隔符。本地 = `std::path::MAIN_SEPARATOR`, - /// 远程 Linux/WSL 恒为 `'/'`。 - fn separator(&self) -> char; - - // ----- 路径算术(§4.3 强制走这里,禁止 `Path::join`) ------------------- - - /// `dir` + `name`。用 `self.separator()`,不用 `PathBuf::push`。 - fn join(&self, dir: &Path, name: &str) -> PathBuf { - default_join(dir, name, self.separator()) - } - - /// 该 host 语义下 `p` 是否是绝对路径。**必须用它,不用 `Path::is_absolute`** - /// —— Windows 客户端上 `/home/me` 的 `is_absolute()` 是 `false` - /// (drive-relative),这正是 `local_cwd()` 挡板文档里那个陷阱。 - fn is_absolute(&self, p: &Path) -> bool; - - // ----- 读 -------------------------------------------------------------- - - /// 一个目录的**已排序**列表(目录在前,然后按 lowercase 名字), - /// 排序规则与 `file_tree::sort_entries` 逐字一致,服务端排好。 - /// - /// `root` 是 gitignore 链的上界(`Entry::ignored` 从 root 走到 dir 逐级 - /// 求值,深者胜,`!` 白名单反转)。`root` 为 `None` 时 `ignored` - /// 除 `.git` 外全 false。 - /// - /// 隐藏文件**不过滤** —— `show_hidden` 是 UI 状态,留在客户端。 - fn read_dir(&self, dir: &Path, root: Option<&Path>) -> io::Result>; - - fn stat(&self, p: &Path) -> io::Result; - - /// `p` 存在与否。`stat().is_ok()` 的省流版;实现可以合并成一次往返。 - fn exists(&self, p: &Path) -> bool { - self.stat(p).is_ok() - } - - /// 读整个文件。`max_bytes` 是**服务端**上限:超过就返回 - /// `ErrorKind::FileTooLarge`(映射见 §6.5)而不是传一遍再丢掉。 - /// 传 `MAX_FILE_BYTES`(`code_editor` 的既有常量)。 - fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result>; - - /// 规范化。远程实现走服务端 `canonicalize`。失败时调用方沿用既有习惯 - /// (`unwrap_or_else(|_| p.to_path_buf())`)。 - fn canonicalize(&self, p: &Path) -> io::Result; - - /// 广度优先的名字包含匹配,**服务端执行**。 - /// - /// 这是设计文档 §9 漏掉的方法。`file_tree.rs::TreeLoader::search` 最多访问 - /// 2000 个目录才停 —— 逐目录 RPC 等于 2000 次往返,跨洲 = 400 秒。必须整个 - /// 搬到服务端。语义与既有实现逐字一致:BFS、跳过 ignored 目录、`limit` 命中 - /// 即停、`max_dirs` 访问上限即停。 - fn search(&self, roots: &[PathBuf], query: &str, limit: usize, max_dirs: usize) - -> io::Result>; - - // ----- 写 -------------------------------------------------------------- - - fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<()>; - - /// 排他创建,已存在即 `AlreadyExists`(对应 `File::create_new`)。 - fn create_file_new(&self, p: &Path) -> io::Result<()>; - - /// `recursive = false` → `fs::create_dir`;`true` → `create_dir_all`。 - /// (设计文档只给了 `create_dir`;`worktree` 写 `.tty7/.gitignore` 要 all。) - fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()>; - - /// 目标已存在时必须返回 `AlreadyExists`,**由实现保证**,不靠调用方先 - /// `exists()` 探一次(那是一次多余往返 + TOCTOU)。 - fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; - - /// `recursive` 只对目录有意义。文件传 `false`。 - fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>; - - // ----- git ------------------------------------------------------------- - - /// `p` 所在仓库的工作树根:最近的、含 `.git`(目录或 worktree 文件)的祖先。 - /// 服务端一次走完,不逐级往返。 - fn repo_root(&self, p: &Path) -> io::Result>; - - /// `git -C `。 - /// - /// 两个实现**必须**统一到同一份不变量(§4.4): - /// `GIT_OPTIONAL_LOCKS=0` + `stdin(null)` + Windows 上 `hide_console`。 - /// - /// 返回 `Ok(Output)` 表示"git 跑起来了",退出码在 `Output::status` 里。 - /// `Err` 只表示"没跑起来"(git 不存在、cwd 不存在、RPC 失败/超时)。 - fn git(&self, cwd: &Path, args: &[&str]) -> io::Result; - - // ----- 监听 ------------------------------------------------------------ - - /// 建立一个长命订阅,初始集合为 `dirs`(可空)。**非递归**。 - fn watch(&self, dirs: &[PathBuf]) -> io::Result; - - // ----- 生命周期 -------------------------------------------------------- - - /// 该 host 当前是否可用。`LocalHost` 恒 `true`;`RemoteHost` 在 - /// `Reconnecting` / `Preempted` 期间为 `false`,调用点据此显示上一份缓存 - /// 而不是错误。 - fn is_connected(&self) -> bool { true } -} - -/// `search` 的一条命中。`path` 是**绝对**路径(服务端用自己的分隔符拼好)。 -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct SearchHit { - pub name: String, - pub path: PathBuf, - pub is_dir: bool, - pub ignored: bool, -} - -/// 便利别名,全树用它。 -pub type SharedHost = Arc; -``` - -### 方法清单与设计文档的差异 - -| 设计文档 §9 | 本契约 | 变化 | -|---|---|---| -| `read_dir(&self, p)` | `read_dir(&self, dir, root)` | 加 `root`:gitignore 链需要上界 | -| — | `search(...)` | **新增**,见上(2000 次往返问题) | -| — | `exists`, `canonicalize` | **新增**,既有调用点用得到(`file_tree:963/998`、`code_editor:328`) | -| — | `create_file_new` | **新增**,`file_tree:953` 用 `create_new` 语义 | -| `create_dir(&self, p)` | `create_dir(&self, p, recursive)` | `worktree` 要 `create_dir_all` | -| `read_file(&self, p)` | `read_file(&self, p, max_bytes)` | 服务端截断,省一次全量传输 | -| — | `id`, `separator`, `join`, `is_absolute` | **新增**,跨 OS 路径算术(§4.3) | -| — | `is_connected` | **新增**,降级态需要它 | -| `watch(&self, dirs) -> WatchSub` | 同名,`WatchSub` 可 `set_dirs` | 订阅长命,集合可变 | - ---- - -## 3. `LocalHost` - -**位置**:`crates/tty7-core/src/host/local.rs` -**负责人**:A2 - -```rust -pub struct LocalHost { /* 无状态,watcher 除外 */ } - -impl LocalHost { - pub fn new() -> Arc { Arc::new(Self { .. }) } -} -``` - -| 约束 | 说明 | -|---|---| -| **零往返、零分配开销** | 每个方法直调 `std::fs` / `Command`。除了 `Vec`/`String` 的必要分配,不许有额外拷贝 | -| **`id()` 恒为 `HostId::LOCAL`** | | -| **`separator()`** | `std::path::MAIN_SEPARATOR` | -| **gitignore** | 用 A1 已提取的 `core::gitignore::GitignoreChain`(`is_ignored(path, is_dir, root)`)。`LocalHost` 内部持 `Mutex` 做 matcher 缓存,不再由调用方 seed/handback(`TreeLoader` 那套来回搬是为了跨线程,`Arc` 之后不需要了) | -| **`watch`** | `notify::recommended_watcher`,`RecursiveMode::NonRecursive`,内部 100ms 合并去重后批量投递 —— **与远程行为逐字一致**,不许本地"顺便"更实时 | -| **`git`** | 三套既有 helper 的合并版,见 §4.4 | - ---- - -## 4. 四个必须钉死的约定 - -### 4.1 `Host` 从哪来 - -- 一个 workspace 一个 `SharedHost`。本地 workspace 拿进程级单例 `LocalHost`。 -- `TerminalView` / `FileTreeState` / `CodeEditor` 都从所属 workspace 取,**不许自己 `LocalHost::new()`**。 -- 进程内有一张 `HostRegistry: HashMap`(`gpui::Global`,住 GUI crate),`HostId` → `SharedHost` 反查用于 `GitStatusCache` 这类只存 id 的地方。 - -### 4.2 `HostId` 的派生串 - -`HostId::from_connection_key` 的输入必须是规范化后的**连接**串,格式固定: - -| host 种类 | key 串 | -|---|---| -| 本地 | 不派生,直接 `HostId::LOCAL` | -| SSH profile | `ssh-profile:` | -| `~/.ssh/config` alias | `ssh-alias:` | -| 裸 `user@host:port` | `ssh-direct:@:`(port 省略时补默认 22) | -| WSL | `wsl:` | - -**不含 `WorkspaceId`** —— 同机多 workspace 共享 `HostId`(设计文档 §10 已定)。 - -### 4.3 跨 OS 路径规则(设计文档完全没提,但是硬伤) - -Windows 客户端连 Linux 远程时,远程路径是 `/home/me/proj`。 - -| `std::path` API | 在 Windows 上对远程 POSIX 路径 | 裁决 | -|---|---|---| -| `join` / `push` | ❌ 产出 `/home/me\proj` | **禁用**。用 `Host::join` | -| `is_absolute` | ❌ 返回 `false`(drive-relative) | **禁用**。用 `Host::is_absolute` | -| `canonicalize` | ❌ 走本地文件系统 | **禁用**。用 `Host::canonicalize` | -| `parent` / `file_name` / `with_file_name` | ✅ Windows 的 `std::path` 把 `/` 也当分隔符 | 可用 | -| `starts_with` / `strip_prefix` / `ancestors` / `components` | ✅ 同上 | 可用 | -| `exists` / `is_dir` / `metadata` / `read_dir` | ❌ 走本地文件系统 | **禁用**。用 `Host::*` | - -**执行方式**:A3 改造完后,`ui/` 与 `terminal/` 里除 `host_ops.rs` 外不得出现 `std::fs::`、`Command::new("git")`、`.canonicalize()`、`.is_absolute()`。加一个 CI grep 守卫(§10.6)。 - -### 4.4 `git` 调用的统一不变量 - -三套既有出口的实测差异: - -| 出口 | 位置 | `GIT_OPTIONAL_LOCKS=0` | `stdin(null)` | cwd 传递 | 错误表示 | 线程 | -|---|---|---|---|---|---|---| -| `git_status::git` | 拆分前 `terminal/git_status.rs:332-345`;A1 已提到 `core::git::git` | ✅ | ✅ | `-C` | `Option`,stderr 丢弃 | background | -| `worktree::git` | `core/worktree.rs:63-75` | ❌ | ❌ | `-C` | `Result`,stderr 是错误文本 | 调用方决定 | -| `app.rs` 内联闭包 | `ui/app.rs:3995-4005` | ❌ | ❌ | `current_dir` | 静默空串 | **UI 线程** | - -**统一后的 `Host::git` 不变量(两个实现都必须满足)**: - -1. `git -C ` —— 一律 `-C`,不用 `current_dir` -2. `GIT_OPTIONAL_LOCKS=0` -3. `stdin` = null -4. stdout **和** stderr 都捕获进 `Output`(不丢 stderr —— `worktree` 需要它做错误文本) -5. Windows 上 `hide_console`(本地实现;远程无此概念) -6. 不继承 GUI 进程的 `GIT_*` 环境变量以外的 git 相关污染 —— 显式 `env_remove("GIT_DIR")`、`env_remove("GIT_WORK_TREE")` - -**调用方适配**(A3 负责): - -| 原调用方 | 适配 | -|---|---| -| `git_status` / `git_diff` | `host.git(...).ok().filter(Output::success).map(\|o\| o.stdout_trimmed())` —— 保留 `Option` 语义 | -| `worktree` | `match host.git(...) { Ok(o) if o.success() => Ok(o.stdout_trimmed()), Ok(o) => Err(o.stderr_trimmed()), Err(e) => Err(format!("failed to run git: {e}")) }` —— 逐字保持既有 `Result` 形状 | -| `app.rs::send_git_diff_to_agent` | 直接 `stdout_trimmed()`,失败当空串(保持既有行为),但整段搬到 background | - -**行为变化警告**:`worktree` 和 `app.rs` 拿到 `GIT_OPTIONAL_LOCKS=0` 是一个真实变化(`git worktree add` 是写操作,`optional_locks=0` 只影响可选锁如 `index.lock` 的刷新,**不影响写操作的必要锁**)。已确认安全,但 M2 的回归测试必须覆盖 `worktree add` / `list` 的完整路径。 - ---- - -## 5. `HostOps`:GUI 侧唯一出口 - -**位置**:`src/ui/host_ops.rs`(留在 GUI crate,因为它认识 `gpui`) -**负责人**:A2 定义,A3 消费 - -```rust -/// GPUI 侧的 Host 门面。把每个阻塞方法包成 -/// `spawn → background_spawn → update` 的三段式,并统一处理 -/// in-flight 去重、staleness、错误通知。 -/// -/// **这是 GUI 里唯一允许触碰 `Arc` 的地方。** -pub struct HostOps; - -impl HostOps { - /// 通用逃生舱:在 background 上跑 `f`,结果回到 UI 线程交给 `land`。 - /// 用于契约里没有专门包装的方法。 - pub fn run( - host: SharedHost, - cx: &mut Context, - f: F, - land: L, - ) where - E: 'static, - T: Send + 'static, - F: FnOnce(&dyn Host) -> T + Send + 'static, - L: FnOnce(&mut E, T, &mut Context) + 'static, - { /* cx.spawn + cx.background_spawn + this.update */ } - - /// 带 window 的变体(需要 `push_notification` / `focus` 的落地)。 - pub fn run_in( - host: SharedHost, window: &mut Window, cx: &mut Context, f: F, land: L, - ) { /* cx.spawn_in */ } -} -``` - -**UI 线程守卫**(debug 构建): - -```rust -// tty7-core::host 里 -#[cfg(debug_assertions)] -pub fn assert_off_ui_thread() { - debug_assert!( - !crate::host::is_ui_thread(), - "Host call on the UI thread — route it through ui::host_ops::HostOps" - ); -} -``` - -`is_ui_thread()` 比对启动时 `main.rs` 注册的 `ThreadId`。每个 `Host` 方法的默认实现入口调一次(release 下被优化掉)。 - -**必备模式(照抄 `terminal/view.rs:3963-3980`)**: - -| 关注点 | 做法 | -|---|---| -| **in-flight 去重** | 每种请求一张 key → bool 表(`file_tree::Loads` 已经是这个形状,复用它的 `begin`/`finish`/`invalidate` 三态) | -| **staleness** | 落地前必须重查前置条件(`remote_path_results` 检查 `self.cmd.text() != line`)。目录列表检查 `Loads::finish` 是否被 `invalidate` 过 | -| **错误** | `Err` 一律 `window.push_notification`,**不许静默**,除非既有行为就是静默(`git` 探针) | -| **断连** | `!host.is_connected()` 时不发请求,直接显示上一份缓存 | - ---- - -## 6. control 连接协议 - -**位置**:`crates/tty7-core/src/daemon/control.rs`(wire 定义 + 编解码 + 测试) -**负责人**:A4 定义并落地 wire,A5 消费 - -### 6.1 kind 数值:**60-63**(两个方向各一套) - -先把号段现状钉死(`src/daemon/protocol.rs:974-1061`): - -| 空间 | 已用 | 保留(注释显式声明) | 退役 | -|---|---|---|---| -| Client → daemon | 1-12, 14-17, 20-22, 30-34, 40, 50 | 15-19(WS3 auth)、20-24(WS4 forward)、30-36(SFTP) | **13**(曾是 `SPAWN_MANAGED_SSH`) | -| Daemon → client | 1-15, 20-22, 30-33, 40, 50 | 同上 | — | - -**分配**: - -| 方向 | 名字 | 值 | payload 形态 | -|---|---|---|---| -| C→S | `CONTROL_HELLO` | **60** | `[JSON]`(无 req_id) | -| C→S | `CONTROL_REQUEST` | **61** | `[u64 req_id][u32 json_len][JSON]` | -| C→S | `CONTROL_REQUEST_BLOB` | **62** | `[u64 req_id][u32 json_len][JSON][raw bytes]` | -| C→S | `CONTROL_CANCEL` | **63** | `[u64 req_id][u32 json_len = 0]` | -| S→C | `CONTROL_HELLO_OK` | **60** | `[JSON]` | -| S→C | `CONTROL_RESPONSE` | **61** | `[u64 req_id][u32 json_len][JSON]` | -| S→C | `CONTROL_RESPONSE_BLOB` | **62** | `[u64 req_id][u32 json_len][JSON][raw bytes]` | -| S→C | `CONTROL_EVENT` | **63** | `[u64 req_id = 0][u32 json_len][JSON]` | - -**为什么是 60-63**: - -| 理由 | 展开 | -|---|---| -| **避开全部保留段** | 15-19 / 20-24 / 30-36 都被注释显式预留给 WS3/WS4/SFTP 的后续扩展。占用它们等于把那三块的扩展空间吃掉 | -| **不复用 13** | 13 是**退役**号,不是空闲号。一个 pre-WS2 的 daemon 会把 kind 13 解成 `SpawnManagedSsh` 并**静默 mis-spawn 一个 pane**,而不是报未知 kind。退役号一律永不复用 | -| **跟随既有分组习惯** | 现有 kind 按 10 分组(10 / 20 / 30 / 40 / 50)。60 是下一个整十位,把整个 control 方言收进一块 | -| **留出 64-69** | 同一块里给后续 control 帧留 6 个号(如未来的流式响应、背压信号),不必再挑新段 | - -**实现前提**:`mod kind` 目前是**私有的**(`protocol.rs:974`)。A4 必须先把它改成 `pub(crate) mod kind`,或把 control kind 定义在 `control.rs` 里自己的 `pub mod kind`。**选后者** —— control 是独立方言,独立号段,独立模块,只在 `control.rs` 的头部注释里交叉引用 `protocol::kind` 的已用表。 - -### 6.2 三种 payload 的精确字节布局 - -所有多字节整数 **little-endian**。外层帧沿用 `[u32 LE payload_len][u8 kind][payload]`,`MAX_FRAME = 64 MiB` 不变。 - -``` -CONTROL_HELLO / CONTROL_HELLO_OK (60) -┌──────────────────────────┐ -│ JSON (payload_len bytes) │ -└──────────────────────────┘ - -CONTROL_REQUEST / CONTROL_RESPONSE (61) -CONTROL_EVENT (63, S→C) -CONTROL_CANCEL (63, C→S) -┌───────────────┬───────────────┬──────────────────┐ -│ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ -└───────────────┴───────────────┴──────────────────┘ - 8 bytes 4 bytes -必须满足:payload_len == 12 + json_n - -CONTROL_REQUEST_BLOB / CONTROL_RESPONSE_BLOB (62) -┌───────────────┬───────────────┬─────────────────┬──────────────────────┐ -│ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ raw blob (剩余全部) │ -└───────────────┴───────────────┴─────────────────┴──────────────────────┘ -blob_len == payload_len - 12 - json_n -``` - -**为什么大 payload 也带 JSON 头**(设计文档 §8 写的是 `[u64 req_id][raw bytes]`,无 JSON): -`write_file` 必须携带目标路径,`read_file` 的响应必须携带 `Meta`(`code_editor` 需要写入后的 mtime,否则要再来一次 `stat` 往返)。裸 blob 形态承载不了参数 —— 这是设计文档 §8 的一处实打实的漏洞(§11 第 7 条)。 - -**为什么事件也带 req_id 且恒为 0**: -冗余 8 字节换来"所有非 HELLO 的 control 帧共享同一个头解析器"。reader 一律 `read u64 → read u32 → 取 JSON`,然后按 kind 分派。`req_id != 0` 的 `CONTROL_EVENT` 是协议错误(`InvalidData`),reader 必须校验。 - -**解码校验(缺一不可)**: - -| 检查 | 违反时 | -|---|---| -| `payload_len >= 12`(61/62/63) | `InvalidData` | -| `12 + json_n <= payload_len` | `InvalidData`(防 `json_n` 溢出导致越界切片) | -| `61`/`63` 上 `12 + json_n == payload_len` | `InvalidData` | -| `CONTROL_EVENT` 的 `req_id == 0` | `InvalidData` | -| `CONTROL_REQUEST` 的 `req_id != 0` | `InvalidData` | -| 未知 kind | `InvalidData`(与既有协议一致,是错误不是跳过) | - -### 6.3 `req_id` 语义 - -| 项 | 规则 | -|---|---| -| **分配方** | 客户端。服务端从不分配 | -| **起点 / 步进** | 从 1 开始,`fetch_add(1)` 单调递增。**0 永久保留给推送** | -| **回绕** | `u64` 不考虑回绕 | -| **匹配** | 乱序。客户端维护 `HashMap>`;响应到达时 `remove` 并投递 | -| **未知 req_id 的响应** | **静默丢弃**,不当错误 —— 超时后取消的请求可能仍会收到迟到的响应 | -| **一请求一响应** | 严格。没有流式、没有多段。大文件靠 `MAX_FRAME` 兜底,超过就 `FileTooLarge` | -| **连接重建** | in-flight 全部以 `ErrorKind::ConnectionReset` 失败;req_id 计数器**不重置**(无所谓,但重置也不会错) | - -### 6.4 每个 RPC 的请求/响应结构 - -**请求 JSON** 是一个 externally-tagged enum: - -```rust -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ControlRequest { - // --- 探活 --- - Ping, - - // --- fs 读 --- - ReadDir { dir: String, root: Option }, - Stat { path: String }, - Exists { path: String }, - Canonicalize { path: String }, - ReadFile { path: String, max_bytes: u64 }, - Search { roots: Vec, query: String, limit: u64, max_dirs: u64 }, - - // --- fs 写 --- - /// blob = 文件内容(走 CONTROL_REQUEST_BLOB) - WriteFile { path: String }, - CreateFileNew { path: String }, - CreateDir { path: String, recursive: bool }, - Rename { from: String, to: String }, - Remove { path: String, recursive: bool }, - - // --- git --- - RepoRoot { path: String }, - Git { cwd: String, args: Vec }, - - // --- watch --- - /// 建立订阅,服务端回 `WatchId` - WatchOpen { dirs: Vec }, - WatchSet { id: u64, dirs: Vec }, - WatchClose { id: u64 }, - - // --- workspace store(M5,A4 只留位,不实现) --- - WorkspaceList, - WorkspaceGet { id: String }, - WorkspacePut { id: String, json: serde_json::Value }, - WorkspaceDelete { id: String }, -} -``` - -**路径一律 `String`,不是 `PathBuf`。** 原因:`PathBuf` 的 serde 在非 UTF-8 路径上的表示是平台相关的,而两端可能是不同 OS。远程侧路径恒为 UTF-8 POSIX;非 UTF-8 路径由服务端在 `read_dir` 时以 lossy 形式返回并标记(与既有 `file_tree` 的 `to_string_lossy` 行为一致)。 - -**响应 JSON**: - -```rust -#[derive(Serialize, Deserialize)] -pub enum ControlReply { - #[serde(rename = "ok")] - Ok(ReplyOk), - #[serde(rename = "err")] - Err(WireError), -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReplyOk { - Unit, - Pong, - Entries(Vec), - Meta(Meta), - Bool(bool), - Path(String), - OptPath(Option), - /// `ReadFile` 的响应:内容在 blob 里,这里只有元信息 - FileMeta { meta: Meta }, - Hits(Vec), - Output(Output), - WatchId(u64), - /// workspace store(M5) - Json(serde_json::Value), -} -``` - -**逐方法对照表**: - -| 请求 | 帧 kind | blob | 响应 `ReplyOk` | 响应帧 kind | -|---|---|---|---|---| -| `Ping` | 61 | — | `Pong` | 61 | -| `ReadDir` | 61 | — | `Entries` | 61 | -| `Stat` | 61 | — | `Meta` | 61 | -| `Exists` | 61 | — | `Bool` | 61 | -| `Canonicalize` | 61 | — | `Path` | 61 | -| `ReadFile` | 61 | — | `FileMeta` | **62**(blob = 内容) | -| `Search` | 61 | — | `Hits` | 61 | -| `WriteFile` | **62** | 内容 | `Meta`(写后的 mtime) | 61 | -| `CreateFileNew` | 61 | — | `Unit` | 61 | -| `CreateDir` | 61 | — | `Unit` | 61 | -| `Rename` | 61 | — | `Unit` | 61 | -| `Remove` | 61 | — | `Unit` | 61 | -| `RepoRoot` | 61 | — | `OptPath` | 61 | -| `Git` | 61 | — | `Output` | 61 | -| `WatchOpen` | 61 | — | `WatchId` | 61 | -| `WatchSet` / `WatchClose` | 61 | — | `Unit` | 61 | -| `Workspace*` | 61 | — | `Json` / `Unit` | 61 | - -`Output.stdout`/`stderr` 是 `Vec`,走 JSON 会被 serde 序列化成数字数组 —— 一个 1MB 的 diff 变成约 4MB JSON。**优化(必做)**:`Output` 的两个字段用 `#[serde(with = "serde_bytes")]`(`Vec` → base64 字符串)。若 A4 觉得 base64 的 33% 仍然贵,可把 `Git` 的响应也走 kind 62(blob = stdout),JSON 只带 `status` 和 stderr。**裁决:v1 用 base64,简单;`git diff HEAD` 的典型量级在 100KB 内,不值得为它做第二条 blob 路径。** 若实测大 repo 上成为瓶颈,再迁到 62 —— 那是纯加法(`ReplyOk::Output` 保留,新增 `ReplyOk::OutputMeta` + blob)。 - -### 6.5 错误编码 - -`io::ErrorKind` 既不是 serde,其变体集也不稳定(`#[non_exhaustive]`)。所以过线用一个**受限的字符串枚举**: - -```rust -#[derive(Serialize, Deserialize)] -pub struct WireError { - pub kind: WireErrorKind, - /// 人类可读,直接进通知。**不含路径以外的服务端内部细节。** - pub msg: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WireErrorKind { - NotFound, - PermissionDenied, - AlreadyExists, - InvalidInput, - NotADirectory, - IsADirectory, - DirectoryNotEmpty, - /// 超过 `max_bytes` - FileTooLarge, - /// 服务端上没有 git / git 起不来 - GitUnavailable, - TimedOut, - /// control 连接断了(客户端本地合成,不过线) - ConnectionReset, - /// 兜底。`msg` 带原始描述 - Other, -} -``` - -**映射表(两个方向都必须实现,且必须是彼此的逆)**: - -| `io::ErrorKind` | `WireErrorKind` | -|---|---| -| `NotFound` | `NotFound` | -| `PermissionDenied` | `PermissionDenied` | -| `AlreadyExists` | `AlreadyExists` | -| `InvalidInput` / `InvalidFilename` | `InvalidInput` | -| `NotADirectory` | `NotADirectory` | -| `IsADirectory` | `IsADirectory` | -| `DirectoryNotEmpty` | `DirectoryNotEmpty` | -| `FileTooLarge` | `FileTooLarge` | -| `TimedOut` | `TimedOut` | -| `ConnectionReset` / `BrokenPipe` / `UnexpectedEof` | `ConnectionReset` | -| 其它 | `Other` | - -反向(`WireErrorKind` → `io::Error`):同表反查,`GitUnavailable` → `io::ErrorKind::NotFound`,`Other` → `io::ErrorKind::Other`。`msg` 一律作为 `io::Error` 的 payload。 - -**关键约束**:`Err` 只表示"操作没能执行"。**`git` 的非零退出码不是 `Err`** —— 它是 `Ok(Output { status: Some(1), .. })`。这一条决定了 `git_status` 的 `Option` 语义能否原样保留。 - -### 6.6 事件推送(`CONTROL_EVENT`, kind 63, req_id = 0) - -```rust -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ControlEvent { - /// 文件变更。服务端按 100ms 滚动窗口合并、去重后批量推。 - /// 一个窗口内的路径去重;窗口内路径数超过 `WATCH_BURST_CAP`(1024) 时 - /// 退化成 `WatchOverflow`,客户端整树 invalidate。 - Watch { id: u64, paths: Vec }, - WatchOverflow { id: u64 }, - - /// M5/M6 保留位(A4 只定义,不实现) - PaneExited { pane_id: u64, code: Option }, - AgentStatus { pane_id: u64, json: serde_json::Value }, - Preempted { by: String }, - WorkspaceChanged { id: String }, -} -``` - -### 6.7 握手:`CONTROL_HELLO` / `CONTROL_HELLO_OK` - -```rust -#[derive(Serialize, Deserialize)] -pub struct ControlHello { - /// 客户端说的 control 方言版本。当前 1。 - pub control_version: u32, - /// 这条 control 连接绑定的 workspace。`None` = 只用 Host RPC, - /// 不绑 workspace(M3 的 stdio 端到端测试走这条)。 - pub workspace: Option, - /// 客户端会话 token + 主机名,用于 §10 的接管。 - pub client_token: String, - pub client_hostname: String, -} - -#[derive(Serialize, Deserialize)] -pub struct ControlHelloOk { - pub control_version: u32, - /// 服务端的 `PROTOCOL_VERSION`(冗余,方便诊断) - pub protocol_version: u32, - /// 服务端二进制版本,display only - pub build: String, - /// 服务端的路径分隔符(客户端据此实现 `Host::separator`) - pub separator: char, - /// 服务端 `$HOME`(首页"新建 workspace 落在 `~`"要用) - pub home: String, - /// 能力位,见 §6.9 - #[serde(default)] - pub features: Vec, -} -``` - -握手失败(`control_version` 不匹配)时服务端回 `CONTROL_HELLO_OK` 里带自己的版本然后**关连接**,客户端据此报明确错误 —— 不发 `CONTROL_RESPONSE`,因为 HELLO 没有 req_id。 - -### 6.8 超时 - -| 类别 | 方法 | 默认 deadline | -|---|---|---| -| 探活 | `Ping` | 5s | -| 元数据 | `ReadDir` `Stat` `Exists` `Canonicalize` `RepoRoot` `Watch*` | **5s** | -| 内容 | `ReadFile` `WriteFile` | **30s** | -| 变更 | `CreateFileNew` `CreateDir` `Rename` `Remove` | **10s** | -| git | `Git` | **20s** | -| 搜索 | `Search` | **20s** | -| workspace | `Workspace*` | 10s | - -**超时行为**: - -1. 客户端本地放弃,向服务端发 `CONTROL_CANCEL { req_id }` -2. 调用方拿到 `io::Error(TimedOut)` -3. 服务端收到 cancel **尽力**中止(`Git` 杀子进程;fs 操作不可中断,跑完丢弃结果)—— **不保证** -4. 迟到的响应到达时 req_id 已不在表里,静默丢弃(§6.3) -5. **超时不断连**。设计文档 §17"单个 `Host` RPC 超时 → 该请求返回 `TimedOut`,不影响其它请求" - -**连接层 keepalive**(与 RPC 超时正交): - -| 项 | 值 | -|---|---| -| 客户端 `Ping` 间隔 | 15s(仅在 30s 内无任何入站帧时才发) | -| 判死阈值 | **45s** 无任何入站帧 | -| 判死后 | 整个 workspace 转 `Reconnecting`(设计文档 §17:control 断 = workspace 断) | - -### 6.9 `PROTOCOL_VERSION` bump 到 3 - -**先说清楚:按 `protocol.rs:42-48` 自己写的规则,新增 kind 是 additive,本来不需要 bump。** 设计文档 §8 直接说"bump 到 3",没给理由。真实理由是另一个: - -> **v3 = "我会说 control 方言"这一能力位可以被正向查询。** - -不 bump 的话,客户端只能靠"开 control 连接 → 收到 unknown kind 错误 → 连接被对端关掉"来探测。而未知 kind 在既有实现里**会直接杀掉那条连接**(`InvalidData`),且与真正的 desync 无法区分。代价是一次无谓往返 + 一条误导性错误日志。 - -同时**新增 `DaemonVersion.features`**(additive,`#[serde(default)]`): - -```rust -pub struct DaemonVersion { - pub protocol: u32, - #[serde(default)] - pub build: String, - /// 细粒度能力位。v3 之后新增能力不再 bump 版本号。 - /// 已定义:`"control"`、`"host-rpc"`、`"workspace-store"`、`"stdio-bridge"` - #[serde(default)] - pub features: Vec, -} -``` - -**兼容性影响**: - -| 组合 | 行为 | -|---|---| -| v3 GUI ↔ v3 daemon/server | 正常 | -| v2 GUI ↔ v3 daemon | ✅ **无降级**。v2 GUI 从不发 60-63;v3 daemon 对既有 kind 的解码逐字不变 | -| v3 GUI ↔ v2 **本地** daemon | ❌ 本地 daemon 不认 60-63。走既有 `spawn::ensure_running` 的不兼容询问路径。**本地 daemon 必须先升到 v3,远程 workspace 才能用** | -| v3 GUI ↔ v2 **远程** server | ❌ 同上,但这是"远程需要升级"的独立提示,不能与本地 daemon 的提示混为一谈 | - -**两次独立握手**(设计文档没说清):远程 workspace 涉及**两个** `PROTOCOL_VERSION` 检查 —— -1. GUI ↔ 本地 daemon(既有 `spawn::ensure_running`,不变) -2. GUI ↔ 远程 tty7-server(**端到端**,穿过本地 daemon 的纯转发层) - -第 2 次必须是端到端的,否则本地 daemon 就得解析 control 流,违背 §6 架构图的"RemoteRouter 纯字节转发,不解析"(§11 第 16 条)。 - ---- - -## 7. `Stream` 抽象(M4) - -### 7.1 复核结论:设计文档 §4 说错了,但结论意外正确 - -| 设计文档 §4 说 | 实际(`daemon/transport.rs:44`, `:397`) | -|---|---| -| "传输抽象:`Stream = Read + Write + try_clone`" | `Stream` 是 **type alias**:Unix `= UnixStream`,Windows `= TcpStream`。不是 trait | -| "多一种传输形态不破坏上层" | **结论成立,理由完全不同** | - -真实理由:**远程流根本不经过 `transport::Stream`。** - -设计文档 §6 自己写了"GUI 侧传输代码一行不改…本地 daemon 对远程流只做字节转发"。所以: - -``` -GUI ──transport::Stream (UnixStream/TcpStream,不动)──> 本地 daemon - │ - RemoteLink (新, async) ──> SSH channel / WSL stdio -``` - -**新抽象住在本地 daemon 里,不在 GUI 里。** 而 daemon 的 SSH 侧已经是 tokio async(russh 就是),所以新抽象应该是 **async 的,且应复用已经存在的形状** —— `daemon/ssh/connect.rs:29-33` 的 `Transport` 枚举(`Tcp` / `Process(ProcessStream)` / `Channel(russh::ChannelStream)`,各自 delegate `AsyncRead`/`AsyncWrite`)。 - -### 7.2 `RemoteLink`(daemon 侧,M4 真正要引入的东西) - -**位置**:`crates/tty7-core/src/daemon/remote_link.rs` -**负责人**:A4(M4 阶段;M3 只需要 `Stdio` 变体) - -```rust -/// 本地 daemon 与远程 tty7-server 之间的一条逻辑流。 -/// -/// 枚举而非 trait object —— 与既有 `ssh::connect::Transport` 同款理由: -/// 每个变体的 `AsyncRead`/`AsyncWrite` 是直接 delegate,没有 vtable。 -pub enum RemoteLink { - /// 首选:`direct-streamlocal@openssh.com` 直连远程 daemon.sock。 - /// russh: `client::Handle::channel_open_direct_streamlocal(socket_path)` - /// (已确认存在于 `russh/src/client/mod.rs:854`,签名 - /// `async fn(&self, S: Into) -> Result, Error>`) - StreamLocal(russh::ChannelStream), - - /// 回退:session channel + `exec tty7-server --stdio`。 - /// 与 StreamLocal 同类型,语义不同 —— 分开是为了让日志/诊断能区分。 - SessionExec(russh::ChannelStream), - - /// WSL:`wsl.exe -d -- tty7-server --stdio` 的 stdin/stdout。 - /// 直接复用既有 `ssh::connect::ProcessStream`(:82-113)。 - Wsl(crate::daemon::ssh::connect::ProcessStream), - - /// CI / 端到端测试:本机 `tty7-server --stdio` 子进程。 - /// 与 Wsl 同类型,分开是为了让测试路径显式。 - LocalStdio(crate::daemon::ssh::connect::ProcessStream), -} - -impl tokio::io::AsyncRead for RemoteLink { /* 四路 delegate */ } -impl tokio::io::AsyncWrite for RemoteLink { /* 四路 delegate,poll_shutdown 全实现 */ } - -impl RemoteLink { - /// 这条流的诊断标签,进日志和状态条。 - pub fn kind_label(&self) -> &'static str { .. } -} -``` - -**为什么四个变体只有两种底层类型**:诊断价值。`StreamLocal` 失败要触发一次性回退探测(设计文档 §7.1"结果缓存在 `SshConnection` 上"),日志里必须能区分"streamlocal 断了"和"stdio bridge 断了"。 - -### 7.3 现有调用方的用法能否满足 - -实测调用点(`transport::Stream` 上的具体方法,全树只有这几处): - -| 位置 | 用法 | 在新架构下 | -|---|---|---| -| `terminal/remote.rs:333` | `stream.try_clone()` | **仍是本地 `UnixStream`**,不受影响 | -| `daemon/server.rs:235` | `read_stream.try_clone()` | 本地 accept 侧,不受影响 | -| `terminal/remote.rs:1059` | `shutdown(Shutdown::Write)`(`kill_pane`) | 本地流,不受影响 | -| `terminal/remote.rs:1488` | `writer.shutdown(Shutdown::Both)` | 本地流,不受影响 | -| `terminal/remote.rs:828` | `set_read_timeout` | 本地流,不受影响 | -| `terminal/view.rs:7123` | `set_read_timeout(250ms)`(DEC 2026 同步更新 deadline) | 本地流,不受影响 | -| `daemon/spawn.rs:179` | `set_read_timeout(HANDSHAKE_TIMEOUT)` | 本地流,不受影响 | - -**结论:`transport::Stream` 一个字都不用改,`try_clone` / `shutdown` / `set_read_timeout` 全部保留。** 这是 M4 最大的好消息,也是设计文档"多一种传输形态不破坏上层"的真实成因。 - -### 7.4 服务端侧确实需要一个阻塞 trait - -`tty7-server --stdio` 模式下,服务端要把自己的 `stdin`/`stdout` 当成一条流用,而 `server.rs` 的 accept 循环是阻塞 + `try_clone` 的形状。 - -**位置**:`crates/tty7-core/src/daemon/duplex.rs` -**负责人**:A5 - -```rust -/// 服务端一条双工流。抽掉 `try_clone` —— stdin/stdout 天然是两个 handle, -/// 无法 clone 出一个双向流。改成构造期 split。 -pub trait Duplex: Send + 'static { - type Read: io::Read + Send + 'static; - type Write: io::Write + Send + 'static; - fn split(self) -> io::Result<(Self::Read, Self::Write)>; -} - -impl Duplex for std::os::unix::net::UnixStream { /* try_clone */ } -impl Duplex for std::net::TcpStream { /* try_clone */ } - -/// `--stdio` 模式:进程的 stdin/stdout。 -pub struct StdioDuplex; -impl Duplex for StdioDuplex { - type Read = std::io::Stdin; - type Write = std::io::Stdout; - fn split(self) -> io::Result<(Stdin, Stdout)> { Ok((io::stdin(), io::stdout())) } -} -``` - -**改动量**:`daemon/server.rs:235` 的 `read_stream.try_clone()?` → `stream.split()?`,一行。这是 M4 **唯一**需要改的既有传输调用点。 - ---- - -## 8. 远程 socket 路径与 `--stdio` 桥 - -**负责人**:A5 - -| 项 | 值 | -|---|---| -| 首选 socket | `$XDG_RUNTIME_DIR/tty7/daemon.sock` | -| 无 `XDG_RUNTIME_DIR` | `~/.local/share/tty7/daemon.sock` | -| 超长路径回退 | 沿用 `transport::socket_path_for` 的短路径 + FNV-1a64 哈希(`transport.rs:59-90`),**原样搬,不重写** | -| 权限 | socket 0600,目录 0700 | -| 粒度 | 一台机器一个 `tty7-server`(per user) | - -**`tty7-server --stdio`** 是一个纯字节转发的小进程:把自己的 stdin/stdout 接到上面那个 socket,不解析任何内容。它**不是** server 本体 —— server 本体是 `tty7-server --daemon`。 - -**`--stdio` 的三个用途**: -1. `AllowStreamLocalForwarding no` 时的回退(设计文档 §7.1) -2. WSL(无 SSH) -3. **CI 端到端测试**(设计文档 §18 的核心杠杆) - ---- - -## 9. 模块归属与 agent 分工 - -### 9.1 crate 布局(A1 产出) - -**A1 已落地**(本契约成文时 working tree 里的实际形状): - -``` -Cargo.toml # workspace root,[patch] 留在这里 -src/ # tty7(GUI bin),依赖 gpui + tty7-core -crates/tty7-core/src/lib.rs # pub mod core; pub mod daemon; —— 无 gpui -crates/tty7-core/src/core/ # agent_hooks cli_agent config crash git gitignore - # osc proc session shells threads window_state worktree -crates/tty7-core/src/daemon/ # mod pane pidfile procinfo protocol remote server - # shell_integration spawn transport winproc + ssh/ -crates/tty7-server/src/main.rs # headless bin,只依赖 tty7-core -``` - -**模块路径刻意保持不变**(`crate::core::config`、`crate::daemon::protocol`),GUI crate 以同名 re-export,所以两侧调用点读起来一样。新增的 `host` 模块**必须遵循同一约定**:`crates/tty7-core/src/host/`,`lib.rs` 加 `pub mod host;`。 - -**已确认的 A1 产出**(不要重做): - -| 模块 | 内容 | -|---|---| -| `core::git` | `probe` / `branch_name` / `git(cwd, args) -> Option`(带 `GIT_OPTIONAL_LOCKS=0` + `stdin(null)`) | -| `core::gitignore` | `GitignoreChain { is_ignored(path, is_dir, root) -> bool, absorb, clear, len }` | -| `core::window_state` | A1 把它搬进了 core(设计文档 §11 说留 GUI)。**不是错误**,接受现状 | - -### 9.2 文件归属表(新增文件) - -| 路径 | crate | 内容 | 负责人 | -|---|---|---|---| -| `crates/tty7-core/src/host/mod.rs` | core | `Host` trait + 辅助类型 + `HostId` + `fnv1a64` | **A2** | -| `crates/tty7-core/src/host/local.rs` | core | `LocalHost`(内部用 `core::git` + `core::gitignore::GitignoreChain`) | **A2** | -| `crates/tty7-core/src/host/conformance.rs` | core | §10 的共用测试套 | **A2** 定义骨架 + 全部 case | -| `src/ui/host_ops.rs` | GUI | §5 门面 | **A2** | -| `src/ui/host_registry.rs` | GUI | `HostId → SharedHost` 的 `gpui::Global` | **A2** | -| `crates/tty7-core/src/daemon/control.rs` | core | control wire(kind / 帧 / enum / 编解码 / round-trip 测试) | **A4**(第一个 commit) | -| `crates/tty7-core/src/host/remote.rs` | core | `RemoteHost`(control 客户端 + req_id 表 + 超时) | **A4** | -| `crates/tty7-core/src/daemon/remote_link.rs` | core | §7.2(M4) | **A4** | -| `crates/tty7-core/src/host/server.rs` | core | control 服务端 handler(`ControlRequest` → `LocalHost`) | **A5** | -| `crates/tty7-core/src/daemon/duplex.rs` | core | §7.4 `Duplex` trait | **A5** | -| `crates/tty7-server/src/main.rs` | server | `--daemon` / `--stdio` / `agent-hook` 三个子命令(A1 已建空壳) | **A5** | -| `src/ui/{file_tree,code_editor,app}.rs`, `src/terminal/{git_status,git_diff,view}.rs` | GUI | 调用点改造 | **A3** | - -**A2 的额外任务**:`core::git::git` 现在返回 `Option`,丢掉了 stderr 和退出码。`Host::git` 需要 `Output`。做法:在 `core::git` 里**新增** `pub fn git_output(cwd, args) -> io::Result` 作为底层,把既有 `git()` 改写成它的薄封装(`git_output(..).ok().filter(Output::success).map(|o| o.stdout_trimmed())`)—— 既有调用方零改动。 -| `src/ui/host_ops.rs` | GUI | **新** —— §5 | **A2** 定义,A3 消费 | -| `src/ui/host_registry.rs` | GUI | **新** —— `HostId → SharedHost` 的 `gpui::Global` | A2 | -| `src/ui/{file_tree,code_editor,app}.rs`, `src/terminal/{git_status,git_diff,view}.rs` | GUI | 改造调用点 | **A3** | - -### 9.3 五个 agent 的分工与依赖 - -| Agent | 范围 | 依赖 | 完成标志 | -|---|---|---|---| -| **A1** | crate 拆分(M1)+ 三处提取(gitignore / git helper / session 数据) | — | `cargo test --workspace` 全绿;`tty7-server` 空壳能在无头 Linux 上 `--version` | -| **A2** | `Host` trait + `LocalHost` + `HostOps` + `HostRegistry` + conformance 骨架 | A1 完成 | conformance 套在 `LocalHost` 上全绿 | -| **A3** | 调用点改造(M2) | A2 的**签名**冻结(不必等实现) | 现有全部测试逐条绿 + §1 豁免清单外零行为变化 | -| **A4** | `control.rs` wire + `RemoteHost` + `remote_link.rs` | A1 完成;**wire 先落地**(见下) | wire round-trip 测试全绿;`RemoteHost` 在 conformance 套上全绿 | -| **A5** | control 服务端 handler + `tty7-server` + `duplex.rs` + stdio 桥 | A4 的 **wire 模块**(不必等 `RemoteHost`) | stdio 端到端:本机起子进程跑通全套 conformance | - -**同步点**: - -``` -A1 ────┬──> A2 ──签名冻结──> A3 - │ - └──> A4 第一个 commit = control.rs wire(纯类型 + 编解码 + 测试) - │ - ├──> A4 继续做 RemoteHost - └──> A5 消费 wire,做服务端 -``` - -**A4 的第一个 commit 必须只含 wire**,不含 `RemoteHost`。这是 A4/A5 并行的唯一前提。 - -**互不重叠保证**: - -| 文件 | 唯一写者 | -|---|---| -| `crates/tty7-core/src/host/{mod,local,conformance}.rs` | A2 | -| `src/ui/{host_ops,host_registry}.rs` | A2 | -| `crates/tty7-core/src/host/remote.rs`, `crates/tty7-core/src/daemon/{control,remote_link}.rs` | A4 | -| `crates/tty7-core/src/host/server.rs`, `crates/tty7-core/src/daemon/duplex.rs`, `crates/tty7-server/**` | A5 | -| `src/ui/{file_tree,code_editor,app}.rs`, `src/terminal/*` | A3 | -| 搬迁、`Cargo.toml`、`.github/**` | A1(A1 完成后,各 agent 只加自己的依赖行) | - -**三处共享文件的追加规则**(避免撞车): - -| 文件 | 规则 | -|---|---| -| `crates/tty7-core/src/lib.rs` | 只有 A2 加 `pub mod host;`,一行 | -| `crates/tty7-core/src/daemon/mod.rs` | A4 加 `pub mod control;` + `pub mod remote_link;`;A5 加 `pub mod duplex;`。各加各的行,不重排 | -| `crates/tty7-core/src/core/git.rs` | 只有 A2 动(加 `git_output`),A3 只读 | - ---- - -## 10. conformance 测试规格 - -**位置**:`crates/tty7-core/src/host/conformance.rs` -**负责人**:A2 定义 + 落地全部 case,A4/A5 只接线 - -### 10.1 组织方式:`&dyn Host` + 宏展开成独立 `#[test]` - -**不用泛型函数** —— `Host` 已经 object-safe(这是 §1 保持阻塞签名的直接收益),`&dyn Host` 更简单且能验证 object safety 本身。 - -**不用单个大函数** —— 失败必须定位到具体 case,一个 `Vec` 汇总会让 CI 输出难读。 - -```rust -// conformance.rs - -/// 每个 case 的签名。`h` 是被测 host,`sandbox` 是一个该 host 上的空目录 -/// (本地是 tempdir,远程是服务端上的 tempdir)。 -pub type Case = fn(h: &dyn Host, sandbox: &Path); - -/// 全部 case 的注册表:`(名字, 函数)`。 -pub const CASES: &[(&str, Case)] = &[ - ("read_dir_lists_and_sorts", read_dir_lists_and_sorts), - ("read_dir_missing_is_not_found", read_dir_missing_is_not_found), - // ... -]; - -/// 为一个 host 工厂展开出全部 `#[test]`。 -#[macro_export] -macro_rules! host_conformance_suite { - ($modname:ident, $factory:expr) => { - mod $modname { - $crate::host::conformance::declare_cases!($factory); - } - }; -} -``` - -`declare_cases!` 用一个 `const` 列表配合 `seq`-风格展开做不到 —— Rust 宏不能遍历 `const` 数组。**实际做法**:把 case 名字写进宏本身。 - -```rust -// conformance.rs 里 -#[macro_export] -macro_rules! for_each_host_case { - ($cb:ident) => { - $cb!(read_dir_lists_and_sorts); - $cb!(read_dir_missing_is_not_found); - $cb!(stat_reports_len_and_mtime); - // ... 一行一个 - }; -} - -#[macro_export] -macro_rules! host_conformance_suite { - ($modname:ident, $factory:expr) => { - mod $modname { - macro_rules! __case { - ($name:ident) => { - #[test] - fn $name() { - let (h, sandbox) = ($factory)(); - $crate::host::conformance::$name(&*h, sandbox.path()); - } - }; - } - $crate::for_each_host_case!(__case); - } - }; -} -``` - -**用法**: - -```rust -// host/local.rs 的 tests -host_conformance_suite!(local, || (LocalHost::new(), TempDir::new().unwrap())); - -// host/remote.rs 的 tests(A4) -host_conformance_suite!(remote_stdio, || spawn_stdio_server_and_host()); -``` - -**新增一个 case = 改两处**:`for_each_host_case!` 加一行,写一个同名 `pub fn`。两个 host 自动都跑上。忘记加进宏 = case 不跑 —— 加一个元测试 `every_pub_case_is_registered` 用 `include_str!` + 计数比对来兜底。 - -### 10.2 Case 清单 - -**fs 读** - -| case | 断言 | -|---|---| -| `read_dir_lists_and_sorts` | 目录在前、然后 lowercase 名字序;与 `file_tree::sort_entries` 逐字一致 | -| `read_dir_includes_hidden` | `.hidden` 出现在结果里(过滤是 UI 的事) | -| `read_dir_missing_is_not_found` | `ErrorKind::NotFound` | -| `read_dir_on_a_file_errors` | `NotADirectory`(Windows 上可能是 `Other`,断言"是 Err"即可) | -| `read_dir_marks_dotgit_ignored` | `.git` 的 `ignored == true`,即使没有 `.gitignore` | -| `read_dir_applies_gitignore_chain` | 根 `.gitignore` 有 `*.log`,`src/.gitignore` 有 `!keep.log` → `drop.log` ignored、`src/keep.log` 不 ignored(照抄 `file_tree.rs:1580-1590` 的既有 fixture) | -| `read_dir_without_root_ignores_nothing` | `root = None` 时除 `.git` 外全 `ignored == false` | -| `read_dir_symlink_to_dir_is_dir` | `is_dir == true` 且 `is_symlink == true`(Windows 跳过) | -| `stat_reports_len_and_mtime` | `len` 精确;`mtime` 有值 | -| `stat_missing_is_not_found` | `NotFound` | -| `exists_matches_stat` | `exists(p) == stat(p).is_ok()`,覆盖存在/不存在/无权限三态 | -| `canonicalize_resolves_dotdot` | `a/../b` → `b` | -| `read_file_roundtrips_bytes` | 含 NUL、非 UTF-8 字节、10MB 内容各一 | -| `read_file_over_max_bytes_errors` | `FileTooLarge`,且**不传输内容**(远程侧断言帧大小) | -| `read_file_on_a_dir_errors` | Err | - -**fs 写** - -| case | 断言 | -|---|---| -| `write_file_creates_and_overwrites` | 新建 + 覆盖,返回的 `Meta.mtime` 与随后 `stat` 一致 | -| `write_file_to_missing_parent_errors` | `NotFound`,且**不创建父目录** | -| `create_file_new_rejects_existing` | `AlreadyExists` | -| `create_dir_non_recursive_needs_parent` | 无父目录时 `NotFound` | -| `create_dir_recursive_makes_chain` | 多级一次建成;已存在时 `Ok` | -| `rename_moves_and_rejects_existing_target` | 目标存在 → `AlreadyExists`(**实现保证**,不靠调用方先探) | -| `rename_across_dirs_works` | 同一 host 内跨目录 | -| `remove_file_then_missing` | 删后 `exists == false`;再删 `NotFound` | -| `remove_dir_non_recursive_needs_empty` | 非空且 `recursive = false` → `DirectoryNotEmpty` | -| `remove_dir_recursive_clears_tree` | 深树一次清掉 | - -**git** - -| case | 断言 | -|---|---| -| `repo_root_finds_nearest_git` | 深层子目录 → repo 根;repo 外 → `Ok(None)`(**不是 Err**) | -| `repo_root_handles_worktree_file` | `.git` 是文件(linked worktree)时也认 | -| `git_status_porcelain_reflects_changes` | 在 sandbox 里 `git init` + 造一个改动,`git(["status","--porcelain"])` 的 stdout 含该文件 | -| `git_nonzero_exit_is_ok_not_err` | `git(["rev-parse","--show-toplevel"])` 在非 repo 目录下 → `Ok(Output { status: Some(128), .. })`,**不是 `Err`**。这一条守住 §6.5 的核心约定 | -| `git_missing_binary_is_err` | `PATH` 剥掉 git 后 → `Err`(本地可测;远程侧用一个注入点) | -| `git_optional_locks_env_is_set` | 用 `git(["config","--get","--type=bool","x"])` 测不到 —— 改用 `git(["var","-l"])` 的输出或一个 stub `git` shim 断言 `GIT_OPTIONAL_LOCKS=0` 在 env 里 | -| `git_stdin_is_null` | 造一个会 prompt 的场景(如无 credential helper 的 `git fetch` 到需要密码的 URL),断言**立即失败**而非挂起(带 10s 硬超时) | - -**路径算术** - -| case | 断言 | -|---|---| -| `join_uses_host_separator` | `host.join("/a", "b")` 在远程 host 上恒为 `/a/b`,即使客户端是 Windows | -| `is_absolute_matches_host_semantics` | 远程 host 上 `/home` 是绝对;Windows 客户端的 `Path::is_absolute` 会说 false,`Host::is_absolute` 必须说 true | - -**search** - -| case | 断言 | -|---|---| -| `search_is_breadth_first` | 浅层命中排在深层前 | -| `search_skips_ignored_dirs` | `node_modules`(在 `.gitignore` 里)内的命中不出现 | -| `search_respects_limit` | `limit = 3` 时恰好 3 条 | -| `search_respects_max_dirs` | 大树 + `max_dirs = 2` 时提前停,不挂起 | - -**watch** - -| case | 断言 | -|---|---| -| `watch_reports_create_and_delete` | 建文件 → 事件里含它的路径(2s 内) | -| `watch_is_non_recursive` | 子目录内的变更**不**上报(除非该子目录也在集合里) | -| `watch_set_dirs_adds_and_drops` | `set_dirs` 后新目录有事件、旧目录无事件 | -| `watch_coalesces_within_window` | 100ms 内 50 次写 → 事件批数 ≤ 2 且路径去重 | -| `watch_drop_unsubscribes` | drop `WatchSub` 后再改文件,无事件(远程侧断言服务端 watcher 已释放) | - -**连接语义(只有 `RemoteHost` 有意义,`LocalHost` 上是平凡真)** - -| case | 断言 | -|---|---| -| `is_connected_is_true_when_healthy` | | -| `id_is_stable_across_calls` | | -| `separator_matches_hello` | | - -### 10.3 sandbox 工厂的契约 - -```rust -/// 两个 host 的工厂都必须满足: -/// - 返回一个**空**目录,测试结束时清理 -/// - 该目录在 `h` 的命名空间里(远程时是服务端上的路径) -/// - `git` 可用(工厂负责在里面 `git init` 或提供一个能 init 的环境) -pub trait Sandbox { - fn path(&self) -> &Path; -} -``` - -### 10.4 conformance 之外的测试(设计文档 §18 的其它行) - -| 层 | 位置 | 负责人 | -|---|---|---| -| control wire round-trip | `daemon/control.rs` 的 `mod tests`,照抄 `protocol.rs:1438` 的真 TcpListener 双线程模式 + `:1503` 的逐 variant Cursor 模式 | A4 | -| 帧解码的**恶意输入** | `json_n` 溢出、`payload_len < 12`、`CONTROL_EVENT` 带非零 req_id、未知 kind —— 每条一个 `#[test]`,断言 `InvalidData` 而非 panic | A4 | -| 版本 skew 握手 | `control_version` 不匹配的分支 | A4 | -| 传输:stdio 回退 | 起真的 `tty7-server --stdio` 子进程 | A5 | -| 端到端 | 开 workspace → 开 pane → 断开 → 重连补屏 → 接管 | A5(M3 范围内只做前两步) | -| 回归护栏 | **M1 的现有全部测试逐条绿,不许改测试来适配** | A1 | - -### 10.5 M2 的回归护栏(M2 不是零行为变化,所以要额外的) - -| 守卫 | 内容 | -|---|---| -| `worktree add` / `list` 全路径 | §4.4 给 `worktree` 加了 `GIT_OPTIONAL_LOCKS=0`,必须实测 | -| `code_editor` mtime 冲突检测 | 保存→外部改→重载三态,异步化后仍然正确(`MTime` 纳秒精度是为它设计的) | -| `file_tree` 乐观更新回滚 | 新建/改名/删除失败时行必须恢复 | - -### 10.6 CI grep 守卫(§4.3 的执行手段) - -✅ **已落地**:`.github/scripts/check-host-boundary.sh`,接在 `ci.yml` 的 `host boundary (§10.6)` job 上(**故意不设 required** —— required 只有 `rustfmt` + 三个 `build & test ()`,加进去会当场卡死所有在开的 PR)。 - -本节原来写的裸 grep **不能直接用**,干净树上 42 个命中、一个都不是 git。脚本对它做了两处修正: - -| 问题 | 裸 grep | 脚本 | -|---|---|---| -| 测试体 | `grep -v '#\[cfg(test)\]'` 只滤掉属性行本身,后面整个 test mod 还在扫描范围里(42 中的 23 行) | 扫到收尾的 `#[cfg(test)] mod …` 就截断。**只认后面紧跟 `mod ` 的那一个** —— `presets.rs:540` / `search.rs:513` 是挂在函数上的 `#[cfg(test)]`,截在那儿会把真实代码整段漏掉 | -| 本地路径 | 无豁免,19 处必然本地的路径全报 | (文件, 模式) 二元组允许清单,每条写明"为什么这个路径不可能是远程的";按模式而不是按文件豁免,所以 `app.rs` 豁免了 `create_dir_all` 之后再出现 `std::fs::read_to_string` 照样红 | - -清单目前 19 行,分五类:主题/预设(`presets.rs`、`app.rs` 的打开主题目录)、shell 历史(`history.rs`)、补全(`completion.rs` 远程走 `cwd: None` 分支、`signature.rs` 的 bundled specs)、SSH 私钥读取(`ssh_prompt.rs`、`ssh_connect.rs`)、剪贴板截图暂存(`view.rs` 的 `temp_dir()`)。加一条是有意行为:**路径有可能是 workspace 路径,答案就是 `Host`,不是清单。** - -脚本自身还守两件事:扫描根不存在 → exit 2;扫到的文件数 < 10 → exit 2。"扫了个空然后报绿"比噪音守卫更糟。 - ---- - -## 11. 设计文档勘误清单 - -复核过的每一条,标明设计文档哪一节、实际是什么、本契约怎么处理。 - -| # | 设计文档 | 说的 | 实际 | 本契约 | -|---|---|---|---|---| -| 1 | §4 表格 "传输抽象" | "`Stream = Read + Write + try_clone`",暗示是 trait | **type alias**:`daemon/transport.rs:44` Unix `= UnixStream`,`:397` Windows `= TcpStream` | §7.1 | -| 2 | §4 同上 | "多一种传输形态不破坏上层" | **结论成立,理由完全不同**:远程流根本不经过 `transport::Stream`,它止步于本地 daemon | §7.1-7.3 | -| 3 | §9 | "这些调用点现在**全部已经**在 background executor 上跑…调用点的结构一行不用动" | **只有** `file_tree` 的 `read_dir` + gitignore(`request_load` :448-479)。`code_editor` 9 处、`file_tree` 变更操作 5 处、`app.rs` 的 git 2 处**全在 UI 线程同步跑** | §1,含完整清单 + 豁免的行为变化表 | -| 4 | §19 | "M1 / M2 是纯重构、零行为变化" | **M2 不是**。异步化必然带来 §1 表里那 5 类可见变化 | §1,把变化显式列成豁免清单 | -| 5 | §9 watch 那行 | 把"只 watch 已展开目录、非递归"写成现状 | `file_tree.rs:413` 是 `RecursiveMode::**Recursive**` watch 所有 root。(`code_editor.rs:520` 确实是 NonRecursive —— 两个 watcher 行为不同) | §2 `WatchSub`,明确这是**新行为** | -| 6 | §9 `git` 约定 | "`GIT_OPTIONAL_LOCKS=0` 的只读约定(现在在 `git_status::git` helper 里)" | **三套出口**:`git_status.rs:332`(有)、`worktree.rs:63`(**无**,且返回 `Result`)、`app.rs:3995`(**无**,用 `current_dir`,**UI 线程**) | §4.4 统一表 + 三个调用方的逐一适配 | -| 7 | §8 帧表 | 大 payload 形态 = `[u64 req_id][raw bytes]` | **承载不了参数**。`write_file` 要路径,`read_file` 响应要 `Meta` | §6.2 改为 `[u64 req_id][u32 json_len][JSON][raw]` | -| 8 | §8 | "`PROTOCOL_VERSION` bump 到 **3**",未给理由 | 按 `protocol.rs:42-48` 自述规则,**新增 kind 是 additive,不需要 bump** | §6.9 给出真实理由(能力探测位)+ 新增 `features` 字段让后续能力不再 bump | -| 9 | §9 方法表 | 没有 `search` | `file_tree.rs::TreeLoader::search`(:177+) BFS 最多 2000 目录。逐目录 RPC = 2000 次往返 | §2 新增 `Host::search`,服务端执行 | -| 10 | §9 方法表 | 缺 `exists` / `canonicalize` / `create_file_new` / `create_dir(recursive)` | 既有调用点用得到:`file_tree:963`(`exists`) `:998`(`is_dir`) `:953`(`create_new`)、`code_editor:328`(`canonicalize`)、`worktree` 的 `.tty7/` 要 `create_dir_all` | §2 全部补上 | -| 11 | §11 | "`git_status` 的 shell-out helper **搬**进 tty7-core" 与 "留在 GUI crate:`terminal/*`" | 自相矛盾 —— `git_status.rs` 就在 `src/terminal/` | ✅ A1 已按"提取"解决:`core::git`(`probe`/`branch_name`/`git`),`git_status.rs` 本体留 GUI | -| 12 | §11 | "gitignore 解析…搬" | **不是独立模块**,是 `ui/file_tree.rs::TreeLoader::is_gitignored`(:142-175) 的内联实现 | ✅ A1 已提取为 `core::gitignore::GitignoreChain` | -| 12b | §11 | "留在 GUI crate:`core/{actions, window_state, update}`" | A1 把 `window_state` 搬进了 core | 接受现状,不回滚 | -| 13 | §9 表格 | 引 `ui/app.rs:3895` 的 agent diff | 实际 **3978-4015**(`send_git_diff_to_agent`) | §1 表已用真实行号 | -| 14 | §4 | 引 `ui/file_tree.rs:628` 的 repo root | 实际 **626-630**(`repo_root_for`)。另有第二份:`core/worktree.rs:58-60` `is_inside_repo`(返回 `bool`) | §2 `Host::repo_root` 统一两份 | -| 15 | §6 图 | "RemoteRouter ◄── 新:**纯字节转发,不解析**" | 与 §12 的远程 `ensure_running` 潜在冲突:本地 daemon 若要做远程版本握手就必须解析 | §6.9:**GUI 端到端握手**,router 保持纯转发。远程 workspace 有**两次独立**版本检查 | -| 16 | — | 未提及 | `mod kind`(`protocol.rs:974`)是**私有**的,跨模块加 kind 需要先改可见性 | §6.1:control kind 定义在 `control.rs` 自己的 `pub mod kind` 里,不动 `protocol::kind` | -| 17 | — | 未提及 | kind **13** 是**退役**号(曾是 `SPAWN_MANAGED_SSH`),不是空闲号。复用它会让 pre-WS2 daemon 静默 mis-spawn 一个 pane | §6.1:退役号永不复用;选 60-63 | -| 18 | — | 未提及 | **跨 OS 路径算术**:Windows 客户端上 `PathBuf::join("/home/me", "src")` → `/home/me\src`;`"/home/me".is_absolute()` → `false` | §4.3 完整禁用/可用表 + §2 的 `Host::join` / `is_absolute` | -| 19 | — | 未提及 | `Output.stdout` 走 JSON 会膨胀 ~4× | §6.4:`serde_bytes` base64 | -| 20 | 任务简报 | `local_cwd()` 挡板 8 处 | **9 处**:`app.rs:2827/2932/3297/3458/3988/5796`、`view.rs:3798/4590/4750`(漏了 `app.rs:2932`) | A3 按 9 处处理 | - -**复核通过、无偏差的**(不必再查): - -| 项 | 结论 | -|---|---| -| §11"`src/daemon/` 依赖的 core 模块只有 7 个" | ✅ 实测正好 `agent_hooks` `cli_agent` `config` `osc` `proc` `shells` `threads` | -| 帧格式 / `MAX_FRAME = 64 MiB`(:33) / `write_frame`(:1064) / `read_frame`(:1081) / `take_frame`(:1105) | ✅ | -| 热路径零序列化、冷路径 JSON tuple(`Spawn` → `(cwd, size)`, :1147) | ✅ | -| `PROTOCOL_VERSION = 2`(:59),bump 规则 :42-57 | ✅ | -| 未知 kind → `InvalidData`(:1287-1292) | ✅ | -| 一条连接一个 pane、控制类短连接、无 req_id、无多路复用 | ✅ | -| round-trip 测试模式 :1438 / :1503 | ✅ | -| russh `channel_open_direct_streamlocal` 存在于 fork `0d1d073` 的 `client/mod.rs:854`,签名如简报所述 | ✅ | -| `file_tree` 的 `read_dir` + gitignore 在 background(:456-466) | ✅ | -| `GitStatusCache` 5 张 `PathBuf` 表 + `impl gpui::Global`(:128-149) | ✅ | -| 远程路径补全的分流写法(`view.rs:3953-4020`)可作模板 | ✅ 已定为 §5 的强制模式 | -| `daemon/ssh/connect.rs:29-33` 已有 `Transport` 枚举(Tcp/Process/Channel),是 §7.2 的现成形状 | ✅ 新发现,直接复用 | - ---- - -## 12. 明确不在本契约内 - -留给后续里程碑,本波 agent **不要**动: - -| 项 | 归属 | -|---|---| -| 安装 / 下载 / sha256 / `uname` 解析 | M4 | -| SSH 侧的 `RemoteRouter` 接线、连接复用 | M4 | -| workspace store 的服务端实现(本契约只定 RPC 位) | M5 | -| `Workspace.host` 字段、`RemoteRef`、首页「连接主机」 | M5 | -| 重连退避 / 接管 / 启动排队认证 | M6 | -| 端口转发的 workspace 维度、SFTP 接线 | M7 | -| WSL | M8 | diff --git a/docs/remote-server-assets.md b/docs/remote-server-assets.md deleted file mode 100644 index 7ca92222..00000000 --- a/docs/remote-server-assets.md +++ /dev/null @@ -1,122 +0,0 @@ -# `tty7-server` release assets - -Contract between the **release workflow** (which produces the assets) and the -**client installer** (`§12` of `2026-07-27-remote-workspace-design.md`, which -downloads and verifies them). Both sides must agree literally — the client -derives the asset name mechanically from `uname -sm`, with no discovery step and -no listing of the release. - -## Asset names - -``` -tty7-server- -``` - -| Asset | Target | Linkage | -|---|---|---| -| `tty7-server-x86_64-unknown-linux-musl` | `x86_64-unknown-linux-musl` | static (`crt-static`, no interpreter) | -| `tty7-server-aarch64-unknown-linux-musl` | `aarch64-unknown-linux-musl` | static (`crt-static`, no interpreter) | -| `checksums.txt` | — | sha256 of **every** asset in the release | - -**No version in the filename.** The version lives in the release tag (i.e. in the -download URL) and in the remote install path (`§12`), never in the asset name. -That keeps the `uname -sm` → filename mapping a pure function with nothing to -interpolate, and makes `…/releases/latest/download/tty7-server-` a -permanently valid "current stable server" URL. - -**Static is a guarantee, not a hope.** The release job asserts it -(`.github/scripts/assert-static.sh`): the binary must report `statically linked`, -carry no ELF interpreter, and declare no `DT_NEEDED` shared libraries, or the job -fails. D10 exists so one binary runs on any distro without regard to the target -machine's glibc — a dynamically-linked build would silently break that on the -first old CentOS box, far from the change that caused it. - -**Size** is roughly **6 MB** (stripped, release, x86_64). Worth knowing because -§12 requires the first-install confirmation to tell the user how much is about to -be written to their machine — quote the `Content-Length`, but this is the -expected order of magnitude. - -## `uname -sm` → asset - -| `uname -s` | `uname -m` | Asset | -|---|---|---| -| `Linux` | `x86_64`, `amd64` | `tty7-server-x86_64-unknown-linux-musl` | -| `Linux` | `aarch64`, `arm64`, `armv8l`, `armv8b` | `tty7-server-aarch64-unknown-linux-musl` | -| `Linux` | anything else | **unsupported** — abort with the raw `uname -sm` in the message | -| anything else | — | **unsupported** — abort | - -- **Match on the exact strings, then fail.** No prefix matching, no "probably - arm" heuristics: installing the wrong architecture produces an `Exec format - error` far from the cause. An unknown machine string is a clean, explainable - refusal. -- **`aarch64` is what Linux actually reports**; `arm64` is accepted because some - container images and BSD-flavoured userlands normalise to it. -- **32-bit is deliberately absent.** No `i686`, no `armv7l`, no `riscv64` — add a - row *and* a CI target together if that ever changes. - -## Download URL - -``` -https://github.com/l0ng-ai/tty7/releases/download// -``` - -| Client version | `` | -|---|---| -| `26.7.5` | `v26.7.5` | -| `26.7.6-nightly.20260727` | `nightly` | - -The nightly channel publishes to a **single rolling `nightly` tag** whose assets -are replaced every night, so a nightly client must not ask for -`v26.7.6-nightly.20260727` — that tag does not exist. Rule: version contains -`-nightly.` → tag is `nightly`; otherwise tag is `v` + version. - -## Verifying (`§16`) - -`checksums.txt` is GNU coreutils `sha256sum` format — 64 lowercase hex chars, two -spaces, the bare asset filename (digests below are illustrative, not real): - -``` -3f786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1 tty7-server-x86_64-unknown-linux-musl -9e107d9d372bb6826bd81d3542a419d6f0d1b0b6c1c1c1c1c1c1c1c1c1c1c1c1 tty7-server-aarch64-unknown-linux-musl -``` - -1. **Fetch `checksums.txt` from the same release** as the binary. HTTPS to - `github.com` is the trust anchor; the file is not separately signed. -2. **Find the line whose filename field equals the asset name** — exact match on - the whole field. Do not substring-search: `tty7-server-x86_64-unknown-linux-musl` - is a substring of nothing today, but that is an accident, not a rule. -3. **Compare hex case-insensitively** against the sha256 of the bytes actually - downloaded. -4. **Absent line, malformed line, or mismatch → abort the install.** Do not - retry, do not fall back to an unverified install, do not write the temp file - through (`§17`). Report the expected and actual digests. - -The digest covers the raw asset bytes, i.e. exactly what gets SFTP-put to -`~/.local/share/tty7/bin/.tty7-server-.tmp` before the `chmod 0755` + -rename. - -## Where this is produced - -| Workflow | Job | Note | -|---|---|---| -| `.github/workflows/release.yml` | `server-musl` → `draft-release` | tagged releases; `checksums.txt` is generated in the assemble job over all collected assets | -| `.github/workflows/nightly.yml` | `server-musl` → `publish` | same assets on the rolling `nightly` tag | -| `.github/workflows/ci.yml` | `server-musl` | compile-only guard on PRs; publishes nothing | - -## The Windows build bundles one of them (WSL) - -A WSL distro is **not** served from a release download. Design §12: it gets the -Linux binary the Windows client already shipped with, because the distro is on -the same machine and there is no network hop worth making. - -| | | -|---|---| -| Which asset | `tty7-server-x86_64-unknown-linux-musl` only — there is no ARM64 Windows target in the matrix. Add the aarch64 one *with* that target, not before | -| Where it lands | `\server\`, in both the installer and the portable zip | -| Who looks there | `daemon::install::wsl` — `BUNDLED_SUBDIR`; it also accepts `\`, and `TTY7_BUNDLED_SERVER_DIR` overrides both | -| If it is missing | The build still ships (a warning, mirroring `server-musl`'s own skip-don't-fail probe). A WSL connect then fails with `MissingBundled`, naming every directory searched — it never silently falls back to downloading | - -**This makes `build` depend on `server-musl`** in `release.yml` and -`nightly.yml`, so the two no longer run in parallel. The directory name is a -contract with `wsl.rs`, not a packaging detail — changing it on one side breaks -WSL on the other. diff --git a/src/core/session.rs b/src/core/session.rs index 54e1ee45..f15adee4 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -84,6 +84,7 @@ impl WorkspaceStore { // workspace whose machine is unreachable must open empty. See // [`claimable_session`]. let reachable = id.is_none_or(|id| Self::machine_is_connected(cx, id)); + let instance = Self::serving_instance(cx, id); let Some(store) = Self::try_store(cx) else { // No store (tests): hand back a detached identity so the window // still builds, but nothing is persisted. @@ -99,7 +100,10 @@ impl WorkspaceStore { }; workspace.open = true; workspace.touch(); - let claimed = (workspace.id, claimable_session(workspace, reachable)); + let claimed = ( + workspace.id, + claimable_session(workspace, reachable, instance.as_deref()), + ); store.workspaces.active = Some(claimed.0); store.workspaces.save(); claimed @@ -118,6 +122,7 @@ impl WorkspaceStore { // describing that machine's layout, so it does not get to overwrite the // copy we have of it — see [`record_session`]. let reachable = Self::machine_is_connected(cx, id); + let instance = Self::serving_instance(cx, Some(id)); let Some(store) = Self::try_store(cx) else { return; }; @@ -126,7 +131,7 @@ impl WorkspaceStore { // tearing down); nothing to record. return; }; - record_session(workspace, session, reachable); + record_session(workspace, session, reachable, instance); if let Some(window) = window { workspace.window = Some(window); } @@ -201,6 +206,29 @@ impl WorkspaceStore { store.workspaces.save(); } + /// Drop the pane ids a workspace claims, keeping its layout. Answers + /// whether anything changed, so a caller can skip the follow-up push to a + /// remote that owns the record. + /// + /// Called right after those panes have been killed — see + /// [`Workspace::forget_pane_ids`] for why the ids have to go rather than + /// being left for the reattach to trip over. + pub fn forget_pane_ids(cx: &mut gpui::App, id: WorkspaceId) -> bool { + let Some(store) = Self::try_store(cx) else { + return false; + }; + let Some(workspace) = store.workspaces.get_mut(id) else { + return false; + }; + let forgotten = workspace.forget_pane_ids(); + if forgotten == 0 { + return false; + } + store.workspaces.save(); + log::info!("workspace {id} forgot {forgotten} pane id(s): its sessions were ended"); + true + } + /// Forget a workspace entirely — the explicit "Close Workspace" action. /// The caller is responsible for killing its daemon panes first; this only /// drops the bookkeeping. @@ -215,7 +243,7 @@ impl WorkspaceStore { store.workspaces.save(); } - // ----- the client / remote storage split (design §10) ------------------- + // ----- the client / remote storage split ------------------- /// The machine a workspace's panes are on. `HostId::LOCAL` for a workspace /// this client owns, and for an id that is no longer on file — a window @@ -243,6 +271,60 @@ impl WorkspaceStore { crate::ui::remote_connect::RemoteConnections::get(cx, host.host_id()).is_some() } + /// The process whose pane ids this workspace's record is about: this + /// machine's daemon for a local workspace, the far machine's `tty7-server` + /// for a remote one. `None` when it cannot be named — an older peer, a + /// machine not connected right now, or a brand-new workspace with no host + /// yet — which every reader treats as "no instance check possible". + /// + /// One function for both because [`Workspace::daemon_instance`] means the + /// same thing on both sides. It used to be local-only, on the reasoning + /// that a remote server's identity is tracked live per connection instead + /// — but that live map lives in memory, so it is empty on the launch that + /// matters most: the one where the client was closed while the remote + /// server was replaced. + pub fn serving_instance(cx: &mut gpui::App, id: Option) -> Option { + match id.and_then(|id| Self::remote_ref(cx, id)) { + Some(host) => crate::ui::remote_connect::RemoteConnections::get(cx, host.host_id()) + .map(|h| h.peer().instance.clone()) + .filter(|instance| !instance.is_empty()), + None => crate::daemon::spawn::local_daemon_instance(), + } + } + + /// Blank `id`'s saved pane ids when they were recorded against a different + /// server process than `instance`, and persist that. Answers whether any + /// were dropped. + /// + /// The remote counterpart of the check [`claimable_session`] runs for a + /// local workspace at claim time. It cannot run there for a remote one: at + /// claim time the machine is usually not connected yet, so there is no + /// instance to compare against. The reconnect is the first moment the + /// answer exists, which is where this is called from. + pub fn forget_stale_pane_ids(cx: &mut gpui::App, id: WorkspaceId, instance: &str) -> bool { + let current = (!instance.is_empty()).then_some(instance); + let Some(store) = Self::try_store(cx) else { + return false; + }; + let Some(workspace) = store.workspaces.get_mut(id) else { + return false; + }; + let dropped = workspace.forget_stale_pane_ids(current); + if dropped == 0 { + return false; + } + // Stamped now rather than left for the next save: the record has just + // been made to describe *this* server, and a crash before the window + // saves must not leave it claiming the old process again. + workspace.daemon_instance = current.map(str::to_string); + store.workspaces.save(); + log::info!( + "workspace {id}: {dropped} saved pane id(s) belong to a previous \ + tty7-server process; rebuilding from the layout" + ); + true + } + /// The client-side entry for `host` — the existing one if this machine has /// seen that workspace before, a fresh one otherwise. /// @@ -366,10 +448,34 @@ pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool { /// touching the cached layout**, and /// [`crate::ui::remote_workspace`]'s connect path rebuilds the window the moment /// the machine answers. -fn claimable_session(workspace: &mut Workspace, reachable: bool) -> Session { +/// `current_instance` is the identity of the process serving this workspace's +/// panes (see [`WorkspaceStore::serving_instance`]). A workspace whose saved ids +/// were recorded against a different one blanks them first — after a restart the +/// numbers begin again at 1, so a stale id would otherwise pass the aliveness +/// check by landing on whatever unrelated pane holds it now. Blanked in the +/// stored entry too, not just the returned copy, so the record stops claiming +/// panes that no longer exist even if the window never saves again. +/// +/// A remote workspace usually reaches the early return above instead: at claim +/// time its machine is not connected yet, so there is no instance to compare and +/// no layout to hand back. `remote_workspace::finish_attempt` runs the same +/// check the moment the connect answers, which is the first point it can. +fn claimable_session( + workspace: &mut Workspace, + reachable: bool, + current_instance: Option<&str>, +) -> Session { if workspace.is_remote() && !reachable { return Session::default(); } + let dropped = workspace.forget_stale_pane_ids(current_instance); + if dropped > 0 { + log::info!( + "workspace {}: {dropped} saved pane id(s) belong to a previous serving \ + process; restoring with fresh shells (and agent resume where recorded)", + workspace.id + ); + } workspace.session.clone() } @@ -381,11 +487,26 @@ fn claimable_session(workspace: &mut Workspace, reachable: bool) -> Session { /// it records nothing rather than replacing the copy we have with the wreckage. /// The remote's own `workspaces.json` is still the authority; this entry is the /// cache the next launch opens from. -fn record_session(workspace: &mut Workspace, session: Session, reachable: bool) { +/// The record is stamped with the process its pane ids came from (`instance`): +/// this machine's daemon for a local workspace, the far machine's +/// `tty7-server` for a remote one. That is what lets the next launch tell a +/// surviving process from a replaced one — see [`claimable_session`] and +/// [`WorkspaceStore::forget_stale_pane_ids`]. +/// +/// The unreachable early return doubles as the guard on that stamp: with the +/// machine down there is no instance to record, and writing `None` over a good +/// one would throw away the very comparison the next connect needs. +fn record_session( + workspace: &mut Workspace, + session: Session, + reachable: bool, + instance: Option, +) { if workspace.is_remote() && !reachable { return; } workspace.session = session; + workspace.daemon_instance = instance; } #[cfg(test)] @@ -427,7 +548,7 @@ mod tests { #[test] fn a_local_workspace_stores_its_own_layout() { let mut workspace = Workspace::default(); - record_session(&mut workspace, local_layout(), true); + record_session(&mut workspace, local_layout(), true, None); assert_eq!(workspace.session.tabs.len(), 1); assert_eq!(workspace.pane_ids(), vec![7]); } @@ -439,7 +560,7 @@ mod tests { #[test] fn a_connected_remote_workspace_stores_its_layout() { let mut workspace = Workspace::on_remote(remote_ref()); - record_session(&mut workspace, local_layout(), true); + record_session(&mut workspace, local_layout(), true, None); assert_eq!(workspace.session.tabs.len(), 1); assert_eq!( workspace.pane_ids(), @@ -448,6 +569,35 @@ mod tests { ); } + /// A remote workspace's record is stamped with the **server's** instance, + /// not left blank. That stamp is the only part of "which process minted + /// these ids" that survives the client being closed, and it is what the + /// next connect compares against before re-attaching anything. + #[test] + fn a_connected_remote_workspace_records_the_serving_instance() { + let mut workspace = Workspace::on_remote(remote_ref()); + record_session( + &mut workspace, + local_layout(), + true, + Some("server-a".to_string()), + ); + assert_eq!(workspace.daemon_instance.as_deref(), Some("server-a")); + } + + /// …and an unreachable machine does not un-stamp it. `None` there means + /// "nobody to ask", and writing it over a good value would disarm the very + /// check the next connect needs — the ids would look current again. + #[test] + fn an_unreachable_remote_window_does_not_erase_the_recorded_instance() { + let mut workspace = Workspace::on_remote(remote_ref()); + workspace.session = local_layout(); + workspace.daemon_instance = Some("server-a".to_string()); + record_session(&mut workspace, Session::default(), false, None); + assert_eq!(workspace.daemon_instance.as_deref(), Some("server-a")); + assert_eq!(workspace.session.tabs.len(), 1, "and the layout stays too"); + } + /// A local workspace opens on the layout it saved. #[test] fn a_local_workspace_reopens_its_saved_layout() { @@ -455,19 +605,55 @@ mod tests { session: local_layout(), ..Workspace::default() }; - let claimed = claimable_session(&mut workspace, true); + let claimed = claimable_session(&mut workspace, true, None); assert_eq!(claimed.tabs.len(), 1); // And the entry is left alone. assert_eq!(workspace.session.tabs.len(), 1); } + /// Claiming a local workspace whose ids were recorded against a *different* + /// daemon process blanks them — in the returned session **and** in the + /// stored entry. After a reboot the numbers restart from 1, so a stale id + /// passes the aliveness check by landing on whatever unrelated pane holds + /// it now; blanking is what turns that into an honest fresh spawn (with + /// the agent resume the leaf recorded). + #[test] + fn claiming_a_local_workspace_from_another_daemon_process_blanks_its_ids() { + let mut workspace = Workspace { + session: local_layout(), + daemon_instance: Some("previous-boot".into()), + ..Workspace::default() + }; + let leaf_id = |session: &Session| match &session.tabs[0].pane { + SessionPane::Leaf { pane_id, .. } => *pane_id, + SessionPane::Split { .. } => panic!("the fixture is a single leaf"), + }; + let claimed = claimable_session(&mut workspace, true, Some("current-boot")); + assert_eq!(claimed.tabs.len(), 1, "the layout still restores"); + assert_eq!( + leaf_id(&claimed), + None, + "but no leaf may attach by a number from a dead daemon" + ); + assert!(workspace.pane_ids().is_empty(), "the entry agrees"); + + // Same process → the ids stay attachable. + let mut workspace = Workspace { + session: local_layout(), + daemon_instance: Some("current-boot".into()), + ..Workspace::default() + }; + let claimed = claimable_session(&mut workspace, true, Some("current-boot")); + assert_eq!(leaf_id(&claimed), Some(7)); + } + /// A connected remote workspace reopens on the layout its machine last /// reported — the read half of "reconnecting gets my tabs back". #[test] fn a_connected_remote_workspace_reopens_its_layout() { let mut workspace = Workspace::on_remote(remote_ref()); workspace.session = local_layout(); - let claimed = claimable_session(&mut workspace, true); + let claimed = claimable_session(&mut workspace, true, None); assert_eq!(claimed.tabs.len(), 1); assert_eq!(workspace.session.tabs.len(), 1); } @@ -482,7 +668,7 @@ mod tests { let mut workspace = Workspace::on_remote(remote_ref()); workspace.session = local_layout(); - let claimed = claimable_session(&mut workspace, false); + let claimed = claimable_session(&mut workspace, false, None); assert!(claimed.tabs.is_empty(), "the window must open with no tabs"); assert_eq!( workspace.session.tabs.len(), @@ -498,7 +684,7 @@ mod tests { fn an_unreachable_remote_window_does_not_overwrite_the_cached_layout() { let mut workspace = Workspace::on_remote(remote_ref()); workspace.session = local_layout(); - record_session(&mut workspace, Session::default(), false); + record_session(&mut workspace, Session::default(), false, None); assert_eq!(workspace.session.tabs.len(), 1); } @@ -550,6 +736,121 @@ mod tests { }); } + /// "End Sessions" kills the panes and then has to say so on file, or + /// reopening the workspace walks into the reattach path with ids nothing + /// answers to. The second call answering `false` is what lets the caller + /// skip the push that follows. + #[gpui::test] + fn forgetting_a_workspaces_panes_is_recorded_once(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + crate::core::config::pin_test_config_dir(); + + let mut entry = Workspace::on_remote(remote_ref()); + entry.session = local_layout(); + let id = entry.id; + WorkspaceStore::install_for_test( + cx, + Workspaces { + workspaces: vec![entry], + active: None, + }, + ); + assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]); + + assert!(WorkspaceStore::forget_pane_ids(cx, id)); + let after = WorkspaceStore::all(cx).get(id).unwrap(); + assert!(after.pane_ids().is_empty()); + assert_eq!( + after.session.tabs.len(), + 1, + "the layout is exactly what reopening rebuilds from" + ); + assert!( + !WorkspaceStore::forget_pane_ids(cx, id), + "nothing left to forget" + ); + }); + } + + /// **The cold-launch half of the restart check.** `RemoteLinks::instances` + /// is in memory, so on the first connect after the client starts every + /// machine is a first sighting and nothing is judged a restart. A server + /// replaced while the client was closed would therefore sail through, and + /// its recycled ids — daemons number panes from 1 — would attach to + /// whatever unrelated shells hold those numbers now. The stamp on the + /// record is what closes that, so this is the test that has to hold. + #[gpui::test] + fn a_remote_workspace_drops_pane_ids_minted_by_a_previous_server( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + crate::core::config::pin_test_config_dir(); + + let mut entry = Workspace::on_remote(remote_ref()); + entry.session = local_layout(); + entry.daemon_instance = Some("server-a".to_string()); + let id = entry.id; + WorkspaceStore::install_for_test( + cx, + Workspaces { + workspaces: vec![entry], + active: None, + }, + ); + + // Same process: these ids still name the panes they always did. + assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "server-a")); + assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]); + + // An unknown instance is never judged — a peer too old to report + // one must not cost the user every pane on the machine. + assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "")); + assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]); + + // Replaced: the claims go, the layout stays, and the stamp moves on. + assert!(WorkspaceStore::forget_stale_pane_ids(cx, id, "server-b")); + let after = WorkspaceStore::all(cx).get(id).unwrap(); + assert!(after.pane_ids().is_empty()); + assert_eq!( + after.session.tabs.len(), + 1, + "the layout is exactly what the rebuild draws from" + ); + assert_eq!( + after.daemon_instance.as_deref(), + Some("server-b"), + "stamped now, so a crash before the next save cannot re-arm the old claim" + ); + + // And the same server is not a restart twice over. + assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "server-b")); + }); + } + + /// **Why clearing the ids locally is not enough.** The remote owns the + /// record, so reopening pulls its copy over the client's — and + /// a copy that still claims the killed panes puts them straight back. This + /// is the constraint `windows::forget_killed_panes` pushes to satisfy; if + /// this assertion ever flips, that push is dead weight. + #[test] + fn a_remote_record_reinstates_pane_ids_a_client_only_clear_dropped() { + let mut theirs = Workspace::on_remote(remote_ref()); + theirs.session = local_layout(); + let record = theirs.to_remote_json(); + + let mut ours = Workspace::on_remote(remote_ref()); + ours.session = local_layout(); + ours.forget_pane_ids(); + assert!(ours.pane_ids().is_empty()); + + ours.apply_remote_json(&record).unwrap(); + assert_eq!( + ours.pane_ids(), + vec![7], + "the machine's copy wins, so the clear has to reach it" + ); + } + /// The remote-bound payload travels under the *remote's* id, so a record /// pushed and pulled back names the same workspace both times. #[test] @@ -575,7 +876,7 @@ mod tests { /// **The window/host invariant, as a test.** /// - /// Design §2: a window is one machine. Design §3 puts the inverse under + /// A window is one machine. The inverse is listed under /// *never do this*, and the M5 data layer spends that guarantee — a /// workspace stores `host` once instead of per pane, and `sidebar_group` /// stays a bare `PathBuf` — so it has to be nailed down rather than diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 4540b73b..738cd53b 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -138,7 +138,7 @@ struct ReaderSignals { } /// The remote workspace a pane belongs to, and how the local daemon reaches its -/// machine (design §15). +/// machine. /// /// A pane of a remote workspace runs on the *remote* `tty7-server`, so nothing /// about it is addressable here by `pane_id`. This is what a pane carries @@ -166,7 +166,7 @@ pub struct PaneWorkspace { impl PaneWorkspace { /// Whether this workspace shares `localhost` with the client, so a - /// `localhost:PORT` link resolves without any forward (design §15's WSL + /// `localhost:PORT` link resolves without any forward (the WSL /// exception). pub fn shares_localhost(&self) -> bool { matches!(self.target, crate::core::session::RemoteTarget::Wsl { .. }) @@ -222,7 +222,7 @@ impl PaneWorkspace { /// the window showing it, and this is the whole of how it says so. The transport /// underneath is identical either way — the same local socket, the same /// `try_clone`, the same reader thread — because the local daemon forwards a -/// routed connection byte for byte (design §6). +/// routed connection byte for byte. #[derive(Clone, Debug, Default)] pub enum PaneRoute { /// This machine's daemon. Every pane before remote workspaces existed, and @@ -398,7 +398,7 @@ impl RemoteTerminal { cwd: Option, shell: Option, ) -> anyhow::Result<(Self, u64)> { - Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell) + Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell, None) } /// [`spawn`](Self::spawn) onto a particular machine. @@ -409,6 +409,11 @@ impl RemoteTerminal { /// What it deliberately does *not* do is restart anything on the far side; a /// remote daemon that is missing or mismatched is `install`'s business, and /// it has already run by the time the ack arrives. + /// `owner` is the workspace the pane will belong to. It only ever reaches + /// the wire for a **local** spawn against a daemon that advertises + /// `pane-owner` — the gate lives in [`spawn_once`](Self::spawn_once), so + /// the retry legs (which may talk to a *different*, freshly started + /// daemon) re-decide it per attempt. pub fn spawn_on( route: &PaneRoute, size: TermSize, @@ -416,10 +421,12 @@ impl RemoteTerminal { cell_h: u16, cwd: Option, shell: Option, + owner: Option, ) -> anyhow::Result<(Self, u64)> { let retry_cwd = cwd.clone(); let retry_shell = shell.clone(); - match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell) { + let retry_owner = owner.clone(); + match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell, owner) { Ok(term) => Ok(term), Err(first_err) if daemon_not_listening(&first_err) => { // Nothing is on the socket: the daemon died (crash, OOM, a stray @@ -431,13 +438,12 @@ impl RemoteTerminal { "daemon not running ({first_err}); starting one failed: {start_err}" )); } - Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell).map_err( - |second_err| { + Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner) + .map_err(|second_err| { anyhow::anyhow!( "daemon not running ({first_err}); started one but Spawn still failed: {second_err}" ) - }, - ) + }) } // **Local panes only.** On a routed pane the connection this reads // as "disconnected" belongs to the *remote* — the local daemon is @@ -458,7 +464,7 @@ impl RemoteTerminal { "daemon disconnected before Spawn reply ({first_err}); restart failed: {restart_err}" )); } - Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell).map_err(|second_err| { + Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner).map_err(|second_err| { anyhow::anyhow!( "daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}" ) @@ -475,10 +481,22 @@ impl RemoteTerminal { cell_h: u16, cwd: Option, shell: Option, + owner: Option, ) -> anyhow::Result<(Self, u64)> { let mut stream = connect_routed(route)?; let win = win_size(size, cell_w, cell_h); + // An owner only goes on the wire when this daemon is known to read the + // `SPAWN_OWNED` frame — an older one drops the connection over the + // unknown kind. Local only for now: a routed spawn's capability set is + // the *remote* server's, which nothing here has interrogated. + let owner = owner.filter(|_| { + route.is_local() + && crate::daemon::spawn::local_daemon_supports( + crate::daemon::protocol::FEATURE_PANE_OWNER, + ) + }); + // Ask the daemon to create the pane, then read its assigned id back. The // very next frames on this connection are this pane's Snapshot + Output, // which the reader thread (started below) will consume. @@ -486,6 +504,7 @@ impl RemoteTerminal { cwd, size: win, shell, + owner, } .encode(&mut stream)?; let pane_id = match DaemonMsg::read(&mut stream)? { @@ -527,16 +546,17 @@ impl RemoteTerminal { let mut stream = connect_routed(route)?; let win = win_size(size, cell_w, cell_h); - // Unlike Spawn there's no synchronous reply to wait for here: the Snapshot - // arrives as the first framed message and is handled uniformly by the - // reader thread (advance + Wakeup), so the screen rebuilds asynchronously. ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?; - let mut term = Self::from_stream(stream, size)?; + // Far enough into the reply to know whether the pane is still there. + // Everything read here is handed to the reader thread rather than + // consumed: a successful attach's first frame is part of the replay. + let buffered = attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route))?; + let mut term = Self::from_stream_with(stream, size, buffered)?; term.route = route.clone(); Ok(term) } - // ── Design §10's pane half of a reconnect ──────────────────────────────── + // ── The pane half of a reconnect ──────────────────────────────── // // For one pane: **reopen the channel, `Attach`, take the replay, resize to // this client's geometry.** It happens *in place* — the same `Term`, the @@ -578,7 +598,7 @@ impl RemoteTerminal { /// start. Advancing that onto a grid that still holds the pre-disconnect /// screen would append a second copy of everything. So the mirror is reset /// and the machine's own record becomes the whole truth — which is also the - /// honest presentation of the replay boundary (design §10): the ring holds + /// honest presentation of the replay boundary: the ring holds /// 8 MiB, a pane that outran it comes back with the daemon's current grid /// and **the middle is genuinely gone**. Nothing here interpolates it, and /// nothing upstream may imply it will fill in later. @@ -626,6 +646,12 @@ impl RemoteTerminal { self.term.clone(), self.proxy.clone(), read_half, + // Nothing pre-read: unlike `attach_on`, a relink does not classify + // the reply. A pane that is gone leaves this one disconnected on + // purpose — the supervisor's retry is the answer here, and spawning + // a fresh shell into a pane the user is still looking at would + // discard the screen it is showing. + Vec::new(), ReaderSignals { cwd: self.cwd.clone(), shell: self.shell_state.clone(), @@ -646,7 +672,7 @@ impl RemoteTerminal { } self.reader_thread = Some(reader); self.route = route.clone(); - // Design §10's last step: "以新客户端的尺寸 Resize". `Attach` carries a + // The last step: "以新客户端的尺寸 Resize". `Attach` carries a // size but deliberately does not resize the PTY, so the geometry only // becomes real when this frame lands — and `synced_size = false` is what // lets it through when the size happens to equal the last one. @@ -658,6 +684,18 @@ impl RemoteTerminal { /// Shared tail of `spawn`/`attach`: build the local `Term`, split the socket /// into read/write halves, and launch the reader thread. pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result { + Self::from_stream_with(stream, size, Vec::new()) + } + + /// [`from_stream`](Self::from_stream) for a caller that has already read + /// part of the stream. `buffered` is where the reader thread starts, ahead + /// of anything still on the socket — `attach_reply_prefix` reads far enough + /// to classify the reply, and those bytes are the front of the replay. + pub(super) fn from_stream_with( + stream: Stream, + size: TermSize, + buffered: Vec, + ) -> anyhow::Result { // Two independent handles to the same connection: the reader thread owns // the read half, the UI thread writes through the (mutex-guarded) write // half. Reads and writes are independent directions, so this is safe. @@ -696,6 +734,7 @@ impl RemoteTerminal { term.clone(), proxy.clone(), read_half, + buffered, ReaderSignals { cwd: cwd.clone(), shell: shell_state.clone(), @@ -744,7 +783,7 @@ impl RemoteTerminal { /// Close this pane's link, leaving the pane running on its machine. /// - /// The same two frames `Drop` sends, without dropping: design §10's + /// The same two frames `Drop` sends, without dropping: the /// takeover needs the client to *stop being attached* while the view stays /// on screen in its read-only state. pub fn detach_link(&mut self) { @@ -754,7 +793,7 @@ impl RemoteTerminal { } // The reader observes the close and runs its own teardown, so the pane // lands in exactly the state a dropped network link leaves it in — which - // is the state design §10 wants after a takeover, reached by the code + // is the state wanted after a takeover, reached by the code // path that is already exercised every time a connection fails. if let Some(handle) = self.reader_thread.take() { let _ = handle.join(); @@ -783,6 +822,9 @@ impl RemoteTerminal { term: Arc>>, proxy: EventProxy, read_half: Stream, + // Bytes already off the socket (see `from_stream_with`), which the loop + // resumes from before its first read. + buffered: Vec, signals: ReaderSignals, ) -> JoinHandle<()> { std::thread::Builder::new() @@ -842,7 +884,7 @@ impl RemoteTerminal { // applied history, and the next pair's Size (ultimately the // final pair, which carries the PTY's current geometry) // restores the recorded width before more bytes advance. - let mut pending: Vec = Vec::new(); + let mut pending: Vec = buffered; let mut pending_size: Option = None; // Sized to the daemon writer's coalesced-frame cap so one large // Output frame lands in a few reads instead of dozens. @@ -1798,7 +1840,7 @@ impl RemoteTerminal { query(pane_id).unwrap_or_default() } - // ── Remote workspaces (design §15) ─────────────────────────────────────── + // ── Remote workspaces ─────────────────────────────────────── /// Send one workspace-scoped request and return the daemon's reply. /// @@ -1920,6 +1962,139 @@ fn daemon_not_listening(err: &anyhow::Error) -> bool { }) } +/// How long to wait for the daemon's first frame after an `Attach` before +/// giving up on *classifying* the reply. Not a deadline on the attach — only on +/// being able to tell "this pane is gone" from "this pane has not said anything +/// yet" — so lapsing costs nothing but the old behaviour. The connection is +/// already open by the time the wait starts (the SSH setup happened inside +/// `connect_routed`), so what is being waited on is one round trip. +/// +/// **The two routes are not the same wait.** A remote attach runs on a +/// background thread and answers over an SSH channel, so it can afford to be +/// patient. A local one is on the UI thread — `ui::pending_pane` explains why +/// that path stayed synchronous — where the ceiling is a window freeze, and a +/// local daemon that has not answered in two seconds is not about to. +fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration { + match route.is_local() { + true => std::time::Duration::from_secs(2), + false => std::time::Duration::from_secs(15), + } +} + +/// Read the head of an `Attach` reply, turning "no such pane" into an `Err`, and +/// hand back whatever was read so the reader thread starts from it. +/// +/// # Why this exists +/// +/// `Attach` has no synchronous reply, so for a long time the client's attach +/// could not fail: it wrote the frame and returned `Ok`, and a pane id that was +/// gone showed up much later as the reader thread hitting EOF — which the view +/// paints as `tty7 — disconnected` and deliberately does *not* close, because +/// on a remote workspace a dropped link and a dead pane look the same from +/// there. So the ordinary case of "that pane isn't there any more" landed the +/// user in the failure state meant for "your machine is unreachable", and +/// `start_pane_spawn`'s fall back to a fresh pane — the whole reason a stale id +/// is survivable — never ran. +/// +/// The daemon does answer, it just answers out of band: `Error` on a miss +/// (`daemon::server`), `Size` + `Snapshot` on a hit. Classifying on the **kind +/// byte** rather than the decoded message is what keeps this cheap — the header +/// is 5 bytes and the snapshot behind it can be megabytes. +/// +/// Two non-answers are deliberately *not* failures, because neither is evidence +/// the pane is gone and both used to work: +/// +/// | | | +/// |---|---| +/// | The read times out | The pane is quiet. Return what we have and let the reader carry on | +/// | Anything but `Error` arrives | It is the replay. Same | +fn attach_reply_prefix( + stream: &mut Stream, + pane_id: u64, + wait: std::time::Duration, +) -> anyhow::Result> { + use std::io::Read as _; + + let _ = stream.set_read_timeout(Some(wait)); + let mut buffered: Vec = Vec::new(); + let mut scratch = [0u8; 4096]; + let mut kind = None; + while kind.is_none() { + match stream.read(&mut scratch) { + Ok(0) => { + // The daemon hung up without saying anything. Only a `Kill` + // racing this attach gets here, and the answer is the same one + // the `Error` frame carries: this pane is not attachable. + let _ = stream.set_read_timeout(None); + return Err(anyhow::anyhow!( + "the daemon closed the connection without answering Attach for pane {pane_id}" + )); + } + Ok(n) => buffered.extend_from_slice(&scratch[..n]), + // A timeout leaves the partial frame in `buffered`, where the + // reader thread resumes it — `take_frame` is written for exactly + // this. + Err(e) if would_block(&e) => break, + Err(e) => { + let _ = stream.set_read_timeout(None); + return Err(anyhow::Error::new(e).context(format!( + "reading the daemon's answer to Attach for pane {pane_id}" + ))); + } + } + kind = crate::daemon::protocol::peek_frame_kind(&buffered); + } + let _ = stream.set_read_timeout(None); + if !kind.is_some_and(crate::daemon::protocol::is_error_kind) { + return Ok(buffered); + } + // An `Error` payload is small and its text is the daemon's own wording for + // what went wrong, so it is worth finishing the frame to quote it. + let message = read_error_frame(stream, &mut buffered, wait) + .unwrap_or_else(|| format!("no such pane {pane_id}")); + Err(anyhow::anyhow!("daemon refused Attach: {message}")) +} + +/// Finish decoding an `Error` frame whose header has already landed in `buffered`. +/// `None` when the rest never arrives — the caller has a serviceable fallback +/// message and no reason to wait around for a better one. +fn read_error_frame( + stream: &mut Stream, + buffered: &mut Vec, + wait: std::time::Duration, +) -> Option { + use std::io::Read as _; + + let _ = stream.set_read_timeout(Some(wait)); + let mut scratch = [0u8; 1024]; + let message = loop { + match crate::daemon::protocol::take_frame(buffered) { + Ok(Some(frame)) => match DaemonMsg::from_frame(frame.0, frame.1) { + Ok(DaemonMsg::Error(message)) => break Some(message), + _ => break None, + }, + Ok(None) => match stream.read(&mut scratch) { + Ok(0) => break None, + Ok(n) => buffered.extend_from_slice(&scratch[..n]), + Err(_) => break None, + }, + Err(_) => break None, + } + }; + let _ = stream.set_read_timeout(None); + message +} + +/// Whether a read failed because its timeout lapsed rather than because the +/// connection broke. The two platforms disagree on which kind a lapsed +/// `SO_RCVTIMEO` produces, so both count. +fn would_block(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) +} + fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause.downcast_ref::().is_some_and(|io| { @@ -2253,7 +2428,7 @@ mod tests { } /// WSL routes by distro and carries no spec, because there is no connection - /// to name (design §7.3). + /// to name. #[test] fn a_wsl_workspace_routes_by_distro() { let ws = PaneWorkspace { @@ -2500,6 +2675,110 @@ mod tests { assert!(!daemon_not_listening(&eof)); } + // ----------------------------------------------------------------------- + // Attach: telling "that pane is gone" from "that pane is quiet". + // ----------------------------------------------------------------------- + + /// **A pane that is gone makes the attach fail.** The regression this + /// exists for: `Attach` has no synchronous reply, so the client used to + /// return `Ok` unconditionally and the daemon's `Error` frame was read much + /// later by the reader thread, which has no arm for it — the socket then + /// closed and the pane landed in the *link is down* state (`tty7 — + /// disconnected`, kept on screen, never respawned) instead of falling back + /// to a fresh shell in `start_pane_spawn`. Ending a workspace's sessions + /// and reopening it hit exactly this. + #[test] + fn an_attach_to_a_missing_pane_is_an_error_not_a_disconnect() { + let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap(); + DaemonMsg::Error("no such pane 7".to_string()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + let err = attach_reply_prefix(&mut client_side, 7, attach_reply_wait(&PaneRoute::Local)) + .expect_err("a missing pane must fail"); + assert!( + format!("{err:#}").contains("no such pane 7"), + "the daemon's own wording is what names which pane went: {err:#}" + ); + } + + /// A daemon that hangs up without answering is the same answer by other + /// means — a `Kill` racing the attach closes the connection. + #[test] + fn an_attach_the_daemon_hangs_up_on_is_an_error() { + let (mut client_side, daemon_side) = UnixStream::pair().unwrap(); + drop(daemon_side); + assert!( + attach_reply_prefix(&mut client_side, 7, attach_reply_wait(&PaneRoute::Local)).is_err() + ); + } + + /// **A local attach's wait is bounded by the UI, not by the network.** It + /// runs synchronously on the UI thread (`ui::pending_pane` explains why), + /// so the wait for the daemon's first frame is a possible window freeze; + /// the remote one is on a background thread and can be patient. Equal + /// numbers here would mean a wedged local daemon freezing restore for + /// fifteen seconds per pane. + #[test] + fn a_local_attach_does_not_wait_as_long_as_a_remote_one() { + let local = attach_reply_wait(&PaneRoute::Local); + let remote = attach_reply_wait(&PaneRoute::for_workspace(Some(&ssh_workspace()))); + assert!(local < remote, "{local:?} must be the shorter wait"); + assert!( + local <= std::time::Duration::from_secs(2), + "the UI thread is holding still for this" + ); + } + + /// **The bytes read to classify the reply are not consumed.** A successful + /// attach's first frame is the head of the replay, so anything the check + /// pulled off the socket has to reach the reader thread — losing it would + /// mean reopening a workspace to a screen missing its first segment. + #[test] + fn a_live_attach_hands_its_replay_bytes_to_the_reader() { + crate::core::config::pin_test_config_dir(); + let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap(); + DaemonMsg::Snapshot(b"hello".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + let buffered = + attach_reply_prefix(&mut client_side, 7, attach_reply_wait(&PaneRoute::Local)) + .expect("a live pane attaches"); + assert!( + !buffered.is_empty(), + "the classification read the Snapshot frame; it must come back" + ); + let term = + RemoteTerminal::from_stream_with(client_side, TermSize::new(80, 24), buffered).unwrap(); + + let mut got = String::new(); + for _ in 0..200 { + { + let t = term.term.lock(); + let grid = t.grid(); + got.clear(); + for col in 0..5usize { + got.push( + grid[alacritty_terminal::index::Line(0)] + [alacritty_terminal::index::Column(col)] + .c, + ); + } + } + if got == "hello" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!( + got, "hello", + "the pre-read replay must still reach the grid" + ); + } + /// Without a real daemon, drive the reader path directly: a `UnixStream::pair` /// stands in for the connection. We hand `RemoteTerminal` one half (as if it /// were the attach'd socket) and push framed `DaemonMsg`s down the other, then diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 0b4698f1..ea32fd23 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -90,6 +90,22 @@ pub struct AuthPromptReady; impl gpui::EventEmitter for TerminalView {} +/// The pane's coding agent reported a different native session id than the one +/// the saved layout knows about — it started a conversation, or replaced the +/// one it had. `Tty7App` subscribes (see `new_terminal`) and re-saves. +/// +/// # Why an event and not just "it's read at save time" +/// +/// The id arrives asynchronously, on the agent's own hooks, long after +/// everything that *structurally* changes a window. Nothing else was making the +/// window save in between, so whether the id reached `session.json` came down +/// to whether the user happened to open a tab, split a pane or move focus +/// afterwards. That is what made resume-after-End-Sessions work sometimes and +/// not others: the layout on file simply had no agent in it. +pub struct AgentSessionChanged; + +impl gpui::EventEmitter for TerminalView {} + /// An established native-SSH daemon pane, ready to be wrapped in a view: the /// output of the fallible [`TerminalView::spawn_native_ssh_terminal`], consumed /// by the infallible [`TerminalView::from_native_ssh_parts`]. @@ -125,6 +141,16 @@ pub struct ShellParts { /// Readable for the same reason as `pane_id` above: killing an orphaned /// pane has to dial the machine it actually landed on. pub(crate) workspace: Option, + /// Whether this is the pane the caller asked to re-attach to, or a fresh + /// one spawned because that id was gone. A restored pane is still running + /// whatever it was running; a respawned one is a bare shell in the same + /// directory, which is the case a saved coding-agent session has to be + /// resumed into. + pub(crate) restored: bool, + /// The workspace whose window created this pane — see + /// [`TerminalView::owner_workspace`]. Rides here for the same + /// cannot-disagree reason as `workspace` above. + pub(crate) owner: Option, } /// See `TerminalView::drag_scroll`. @@ -174,7 +200,7 @@ pub struct TerminalView { /// through [`host`](Self::host) at use time means a reconnect is picked up /// by the next probe with nothing to notify. host_id: crate::ui::host_ops::HostId, - /// The remote workspace this pane belongs to, when it is one (design §15). + /// The remote workspace this pane belongs to, when it is one. /// /// `None` — the case today, until the M5 window/workspace binding calls /// [`set_workspace`](Self::set_workspace) — means a local pane or an SSH @@ -195,6 +221,23 @@ pub struct TerminalView { /// panes. In-memory only (not persisted) — held so splits of this pane /// inherit the same shell. shell_spec: Option, + /// The workspace whose window created this view (spawn or re-attach). + /// `None` only for views built through paths that predate the field (tests, + /// native SSH). `Tty7App::save_session` compares it against the workspace + /// it is about to record under and shouts on a mismatch — a window whose + /// tabs and identity have come apart is exactly the corruption that once + /// copied one workspace's layout into another's record, and it must be + /// caught at the write, not discovered at the next restart. + owner_workspace: Option, + /// Whether this view re-attached to the pane its caller asked for, rather + /// than getting a fresh shell because that pane was gone — [`ShellParts`]'s + /// `restored`, kept because restore has to act on it *after* the view is + /// built. + /// + /// `false` for every view that was never restoring anything (a new tab, a + /// split, a test), which is the same answer those callers already got from + /// "there was no pane id to come back to". + restored: bool, /// The native-SSH spec this pane was spawned with, **secrets stripped** /// ([`NativeSshSpec::without_secrets`]). `None` for local shells (and a /// foreground `ssh` typed in one). Persisted into the session so a *dead* @@ -321,6 +364,14 @@ pub struct TerminalView { /// waiting, working → done) fire exactly one notification each and repaint /// the status dot. last_agent_status: Option, + /// The agent identity that is worth *persisting* — the native session id and + /// the argv it was launched with — as last seen. Compared on every poll so a + /// change raises [`AgentSessionChanged`] and the layout on file catches up. + /// + /// Deliberately not the agent chip itself: that can blip for a moment when + /// the agent shells out, and a blip here would mean a save (and, on a remote + /// workspace, a push) for nothing. The session id does not blip. + last_agent_session: (Option, Option>), /// When the current rich turn entered `Working`, for the "finished after /// Ns" copy on its `Done` notification. agent_turn_started: Option, @@ -510,7 +561,7 @@ enum LoopbackOpen { } /// How a ⌘/Ctrl-clicked URL should be opened, decided before anything is done -/// about it (design §15). +/// about it. /// /// Split out as a pure decision so the branch a pane takes is testable without a /// daemon, a connection, or a browser — the three things this feature otherwise @@ -521,7 +572,7 @@ pub(super) enum LoopbackPlan { /// machine: hand the URL to the OS unchanged. Direct, /// A remote whose `localhost` *is* the client's — WSL shares the network - /// namespace with its Windows host (design §15's exception). No forward is + /// namespace with its Windows host (the exception). No forward is /// built; the original URL already resolves. Wired now so M8 only has to /// start constructing `RemoteTarget::Wsl`. NoForwardNeeded, @@ -998,8 +1049,18 @@ impl TerminalView { /// The PTY lives in the daemon now. On session restore (`restore_pane`), /// re-`attach` to the still-running pane so its process + scrollback come /// back intact; otherwise `spawn` a fresh pane (with the caller's shell - /// pick, if any). The caller only passes a `restore_pane` it has already - /// confirmed alive, so we trust it here. + /// pick, if any). + /// + /// **A `restore_pane` that is gone falls back to a fresh pane** rather than + /// failing. Callers do check first, but neither check is a guarantee: a + /// local one asks the daemon and can be raced by the pane exiting, and a + /// remote one cannot afford to ask at all (`alive_panes_on` is a blocking + /// round trip and the UI thread is where it would run) — trying the attach + /// *is* the question there. Either way an id that no longer exists is the + /// ordinary state of a workspace whose sessions were ended, and the answer + /// to it is the same as for a session written before the daemon existed: a + /// fresh shell in the saved cwd. `restored` says which happened, because + /// what the caller does next differs — see [`ShellParts`]. /// /// **`workspace: None` is the local path, unchanged down to the byte** — /// [`PaneRoute::for_workspace`] answers `Local`, and `Local` is a bare @@ -1015,16 +1076,24 @@ impl TerminalView { working_directory: Option, restore_pane: Option, shell: Option, + owner: Option, ) -> anyhow::Result { let route = crate::terminal::PaneRoute::for_workspace(workspace.as_ref()); - let (terminal, pane_id, shell_spec) = match restore_pane { - Some(id) => ( - RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id)?, - id, + let attached = match restore_pane { + Some(id) => match RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id) { // An attached pane keeps whatever shell it already runs; the // pick that spawned it (if any) isn't persisted. - None, - ), + Ok(terminal) => Some((terminal, id, None)), + Err(e) => { + log::info!("pane {id} is gone on its machine ({e:#}); spawning fresh"); + None + } + }, + None => None, + }; + let restored = attached.is_some(); + let (terminal, pane_id, shell_spec) = match attached { + Some(parts) => parts, None => { let (terminal, id) = RemoteTerminal::spawn_on( &route, @@ -1033,6 +1102,7 @@ impl TerminalView { 17, working_directory, shell.clone(), + owner.map(|id| id.to_string()), )?; (terminal, id, shell) } @@ -1042,6 +1112,8 @@ impl TerminalView { pane_id, shell_spec, workspace, + restored, + owner, }) } @@ -1054,10 +1126,25 @@ impl TerminalView { ) -> Self { let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx); view.shell_spec = parts.shell_spec; + view.owner_workspace = parts.owner; + view.restored = parts.restored; view.set_workspace(parts.workspace); view } + /// Whether this pane came back as the one it was asked to re-attach to. + /// `false` means a fresh shell — see the field, and + /// [`ShellParts::restored`]. + pub(crate) fn restored(&self) -> bool { + self.restored + } + + /// The workspace whose window created this pane, or `None` when the + /// creating path predates the field. See the field for what reads it. + pub fn owner_workspace(&self) -> Option { + self.owner_workspace + } + /// Spawn a native (russh) SSH pane for `spec` and build the view around it /// (PRD FR-C1/E-series). The caller (`ui::ssh_connect`) has already resolved /// keychain secrets into `spec`; this view retains only the **secret-free** @@ -1293,6 +1380,8 @@ impl TerminalView { workspace: None, pane_id, shell_spec: None, + owner_workspace: None, + restored: false, ssh_spec: None, focus_handle, font, @@ -1328,6 +1417,12 @@ impl TerminalView { running_title: String::new(), running_agent: None, last_agent_status: None, + // Empty rather than seeded from the saved layout: a pane that comes + // back attached to a running agent then reports the id it already + // had, which reads as a change and saves once. Harmless, and the + // alternative — trusting the record — would skip the save that + // fixes a record which is *wrong*. + last_agent_session: (None, None), agent_turn_started: None, agent_was_rich: false, agent_result_unread: false, @@ -1431,7 +1526,7 @@ impl TerminalView { /// - **`remote_context`** — the pane's *own* process is elsewhere (a pane /// tty7 dialled over SSH, a `wsl.exe` pane, a foreground `ssh`). The /// daemon reports it, having watched the process. - /// - **`host_id`** — the pane belongs to a **remote workspace** (§15). + /// - **`host_id`** — the pane belongs to a **remote workspace**. /// Nothing about the pane itself is remote *from its own daemon's point /// of view*: `tty7-server` on the far machine spawned an ordinary local /// shell and reports `remote_context: None`, exactly as a local daemon @@ -1450,7 +1545,7 @@ impl TerminalView { /// what "+", a split, and the persisted session hand the new shell. /// /// Deliberately **not** [`local_cwd`](Self::local_cwd), and the difference - /// is the whole point. A window shows one machine (§3), so a sibling lands + /// is the whole point. A window shows one machine, so a sibling lands /// on the machine this pane's shell already runs on: for a remote-workspace /// pane that is the far box, where `/home/me/proj` is exactly right and /// withholding it would open every new tab at `$HOME` instead. @@ -1484,7 +1579,7 @@ impl TerminalView { } /// The remote workspace this pane belongs to, if any — what its port - /// forwards are owned by and whose SSH connection its SFTP rides (§15). + /// forwards are owned by and whose SSH connection its SFTP rides. pub fn workspace(&self) -> Option<&crate::terminal::PaneWorkspace> { self.workspace.as_ref() } @@ -1527,7 +1622,7 @@ impl TerminalView { crate::terminal::PaneRoute::for_workspace(self.workspace.as_ref()) } - /// Design §10's read-only degrade, as the keyboard sees it. + /// The read-only degrade, as the keyboard sees it. /// /// **A local pane always answers `true`** — it has no connection to lose, /// and `workspace()` is `None` for it, so this is a field test and not a @@ -1550,7 +1645,7 @@ impl TerminalView { /// Everything a reconnect needs to know about this pane, read on the UI /// thread before the blocking half runs off it: which pane, and at what - /// geometry to bring it back (design §10: "以新客户端的尺寸 Resize"). + /// geometry to bring it back ("以新客户端的尺寸 Resize"). /// /// The geometry is *this* client's current one, not the one the pane was /// recorded at — a laptop that reconnects to a workspace it left on a @@ -1589,7 +1684,7 @@ impl TerminalView { /// Let go of this pane's link without ending the pane. /// - /// Design §10's takeover: another client attached, so this one stops being + /// The takeover: another client attached, so this one stops being /// the workspace's session. The pane stays on screen, read-only, exactly as /// a dropped link leaves it — what must *not* happen is this client going on /// holding a stream to a workspace somebody else is now typing in. @@ -1791,7 +1886,7 @@ impl TerminalView { // read the same and the wording is unchanged; for a remote // workspace they are opposite facts, and "process exited" on a // pane whose shell is still running on the far machine is the - // one claim design §10's degrade must not make — the whole + // one claim the degrade must not make — the whole // promise is that the work is still there when the link returns. self.title = if self.workspace().is_some() && !self.terminal.child_exited() { "tty7 — disconnected".to_string() @@ -1881,7 +1976,7 @@ impl TerminalView { // early return is unchanged — a local pane's link only dies when its // daemon does. // - // For a remote-workspace pane they are not. Design §10's read-only + // For a remote-workspace pane they are not. The read-only // degrade is precisely the case where the link is gone and the shell is // not: that window must keep scrolling, selecting, copying and // searching, and every one of those runs below this line. What must not @@ -1980,12 +2075,12 @@ impl TerminalView { return; } - // Design §10's read-only degrade, placed **here and not at the top of + // The read-only degrade, placed **here and not at the top of // this function**. // // Everything above is the window's own keyboard, not the machine's: // ⌘F opens the search bar, ⌘A selects, ⌘C copies, ⌘1-9 switches tabs. - // §10 promises every one of those keeps working while the link is + // Every one of those keeps working while the link is // down — "能滚历史、能选能复制、能 ⌘F 搜索" — and a gate at the top of // `on_key_down` would silently take them all away, turning a read-only // window into an inert one. (⌘V is not an exception that needs handling @@ -3563,6 +3658,18 @@ impl TerminalView { self.agent_was_rich = false; } + // Ahead of the status early-return below, because this does not move + // with the status: an id appears when the agent's hooks first report a + // conversation, which is a moment the status has no opinion about. + let identity = ( + session.as_ref().and_then(|s| s.session_id.clone()), + session.as_ref().and_then(|s| s.launch_argv.clone()), + ); + if identity != self.last_agent_session { + self.last_agent_session = identity; + cx.emit(AgentSessionChanged); + } + let status = session.as_ref().map(|s| s.status); if status == self.last_agent_status { return false; @@ -4618,7 +4725,7 @@ impl TerminalView { /// Two shapes qualify, and they are found by different signals: /// - a **native-SSH pane** — tty7 dialled it, so `remote_context` says so /// and the daemon holds the authenticated connection under its pane id; - /// - a **remote-workspace pane** (§15) — its `tty7-server` reports it as + /// - a **remote-workspace pane** — its `tty7-server` reports it as /// an ordinary local pane (it *is* one, over there), so `remote_context` /// is `None` and only this side's `workspace` binding reveals it. The /// connection is the workspace's, not the pane's. @@ -5376,7 +5483,7 @@ impl TerminalView { return LoopbackOpen::NotLoopback; }; // WSL shares the Windows host's `localhost`, so the URL already points at - // the right place — building a forward would be pure overhead (design §15). + // the right place — building a forward would be pure overhead. if matches!(plan, LoopbackPlan::NoForwardNeeded) { return LoopbackOpen::NotLoopback; } @@ -7026,7 +7133,7 @@ mod tests { use gpui_component::IconName; use std::path::{Path, PathBuf}; - // ── ⌘-click `localhost:PORT` routing (design §15) ──────────────────────── + // ── ⌘-click `localhost:PORT` routing ──────────────────────── use crate::core::session::{RemoteTarget, WorkspaceId}; use crate::daemon::protocol::RemoteKind; @@ -7088,7 +7195,7 @@ mod tests { ); } - /// **The WSL exception (design §15).** WSL shares `localhost` with its + /// **The WSL exception.** WSL shares `localhost` with its /// Windows host, so the URL already resolves — building a forward would be /// pure overhead. Wired now; M8 supplies the target. #[test] @@ -8062,6 +8169,77 @@ mod gpui_tests { panic!("the prompt report never reached the view"); } + /// **A session id the agent reports raises [`AgentSessionChanged`], and it + /// does so without the status moving.** That is the whole point: the id + /// arrives on the agent's hooks, minutes after anything structural happened + /// to the window, and nothing else was going to make the layout save. A + /// record with no session id in it is a workspace that cannot resume, which + /// is what made resume-after-End-Sessions look intermittent. + /// + /// The second poll must stay quiet — a save (and, on a remote workspace, a + /// push to the machine) per repaint would be a different bug. + #[gpui::test] + fn a_reported_session_id_asks_the_window_to_save(cx: &mut TestAppContext) { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + let saves = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let view = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + { + let saves = saves.clone(); + cx.update(|cx| { + cx.subscribe(&view, move |_, _: &AgentSessionChanged, _| { + saves.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }) + .detach(); + }); + } + + // The hooks report a conversation. `status` is `Idle` before and after, + // so a poll keyed only on the status would never notice. + DaemonMsg::AgentStatus(Some(AgentSessionState { + status: AgentStatus::Idle, + message: None, + session_id: Some("sid-abc".into()), + launch_argv: Some(vec!["claude".into()]), + rich: true, + cwd: None, + activity: 0, + })) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + if window + .update(cx, |view, _, _| view.terminal.agent_session().is_some()) + .unwrap() + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, _, cx| view.poll_agent_status(false, cx)) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + saves.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the id has to reach the layout on file" + ); + + window + .update(cx, |view, _, cx| view.poll_agent_status(false, cx)) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + saves.load(std::sync::atomic::Ordering::SeqCst), + 1, + "an unchanged session must not re-save on every poll" + ); + } + /// A hover cell remembered while the pane was tall names a row the grid no /// longer has once the pane shrinks (a vertical split, a smaller window). /// Resolving it must decline rather than index the grid — this path runs @@ -9506,7 +9684,7 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), b"ping".to_vec()); } - // ── Design §10's read-only degrade, at the five keystroke entry points ─── + // ── The read-only degrade, at the five keystroke entry points ─── /// Install a store holding one remote workspace with no connection, and /// bind `view` to it. `RemoteLinks` has never heard of the machine, so @@ -9546,7 +9724,7 @@ mod gpui_tests { id } - /// Design §10: a window that is not attached **still shows, scrolls, + /// A window that is not attached **still shows, scrolls, /// selects and searches — but typing goes nowhere**, and nothing is /// buffered for later (D6). /// @@ -9575,7 +9753,7 @@ mod gpui_tests { ); } - /// The rest of design §10's degrade, and the half a gate at the top of + /// The rest of the degrade, and the half a gate at the top of /// `on_key_down` would silently destroy: **a read-only window is not an /// inert one.** /// @@ -9732,7 +9910,7 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), b"z".to_vec()); } - // ── Design §10's reconnect: the pane relink ────────────────────────────── + // ── The reconnect: the pane relink ────────────────────────────── /// The pane half of a reconnect swaps the socket **in place**: same `Term`, /// same event channel, same shared signals — because the view's event pump @@ -9800,7 +9978,7 @@ mod gpui_tests { "the mirror must be reset before the daemon replays onto it" ); - // Design §10's last step: resize to *this* client's geometry. + // The last step: resize to *this* client's geometry. let resize = loop { match ClientMsg::read(&mut new_daemon).expect("the new socket is live") { ClientMsg::Resize(win) => break win, diff --git a/src/ui/app.rs b/src/ui/app.rs index 9af5cdbc..233bb2e3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -24,11 +24,12 @@ use crate::core::config::{ use crate::core::session::{ Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore, }; -use crate::core::shells::DetectedShell; +use crate::core::shells::ShellInventory; use crate::core::ssh_config; use crate::core::window_state::{WindowGeometry as _, WindowState}; use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value}; use crate::terminal::view::{ChildExited, TerminalView}; +use crate::ui::host_registry::HostId; use crate::ui::palette::{ ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView, }; @@ -722,10 +723,19 @@ pub struct Tty7App { /// Keeping something focused keeps keystrokes flowing through the window's /// dispatch path, so ⌘T & friends still reach the root action handlers. pub(crate) home_focus: gpui::FocusHandle, - /// Shells found on this machine (`core::shells::detect_shells`), listed in - /// the "+" dropdown. Probed once at startup off the UI thread — empty until - /// that lands, when the dropdown offers just the default entry. - pub(crate) detected_shells: Vec, + /// The shells of the machine **this window is bound to**, listed in the "+" + /// dropdown. Fetched once per machine off the UI thread — empty until that + /// lands (and while a remote machine is unreachable), when the dropdown + /// offers just the default entry. + /// + /// Not "this computer's shells": a remote workspace's window opens its tabs + /// on the far machine, and a menu built here would offer paths that only + /// exist locally. See [`Tty7App::refresh_shells`]. + pub(crate) shells: ShellInventory, + /// Which machine [`Self::shells`] describes, so a landing fetch for a + /// machine the window has since left can be discarded, and so the menu knows + /// whether the local config's `shell` override applies to it. + pub(crate) shells_host: HostId, /// Pane-contextual SSH loopback forward UI state. The controls render only /// over the active SSH pane, but the input/editing state is app-owned so it /// is not tied to the Settings tab. @@ -810,7 +820,7 @@ pub struct Tty7App { /// `focus_active`, which only takes `&self`. window_title: std::cell::RefCell, /// The home page's "Connect to Host" flow, or `None` when it isn't running - /// (design §10). Lives on the window rather than on the app because a + ///. Lives on the window rather than on the app because a /// window is what a remote workspace ends up bound to — two windows can be /// reaching two different machines at once. pub(crate) connect: Option, @@ -914,7 +924,7 @@ impl Tty7App { Self::prompt_daemon_version_mismatch(window, cx); // The same question for any *remote* server this client already found // at a different build, and the consent handler that the install path - // asks before writing a binary onto someone else's machine (design §12). + // asks before writing a binary onto someone else's machine. crate::ui::remote_connect::register(cx); Self::prompt_remote_daemon_mismatch(window, cx); // A window that came back on a remote workspace has its last-pulled @@ -1136,7 +1146,16 @@ impl Tty7App { // First run (no session file): the very first terminal has no // predecessor to inherit from, so start in the app's current // directory (None → default behavior). - None => match new_terminal(pane_ws.clone(), font_size, None, None, None, window, cx) { + None => match new_terminal( + pane_ws.clone(), + Some(workspace), + font_size, + None, + None, + None, + window, + cx, + ) { Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0), // The daemon we just tried to start isn't answering. A window // with no tabs is a legal state (it shows the home page), and @@ -1148,7 +1167,7 @@ impl Tty7App { }, // A saved session (with tabs, or an empty home-page state): rebuild it // the same way a daemon restart does. - some => tabs_from_session(pane_ws.as_ref(), some, font_size, window, cx), + some => tabs_from_session(pane_ws.as_ref(), workspace, some, font_size, window, cx), }; // Sidebar tab filter. Each keystroke re-renders the (cheap) row list so // results narrow as you type — the same live-filter wiring the theme @@ -1166,7 +1185,7 @@ impl Tty7App { cx.notify(); } }); - let app = Self { + let mut app = Self { tabs, active, font_size, @@ -1193,7 +1212,8 @@ impl Tty7App { mod_hint_gen: 0, record_gen: 0, home_focus: cx.focus_handle(), - detected_shells: Vec::new(), + shells: ShellInventory::default(), + shells_host: HostId::LOCAL, loopback_panel: LoopbackForwardPanelState { form_pane_id: None, managed: Vec::new(), @@ -1245,22 +1265,8 @@ impl Tty7App { if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 { crate::ui::tray::init(cx); } - // Discover this machine's shells for the "+" dropdown off the UI thread - // (the WSL probe on Windows spawns a process, and /etc/shells hits the - // filesystem). Until it lands the dropdown offers just the default entry. - cx.spawn(async move |this, cx| { - let shells = cx - .background_spawn(async { crate::core::shells::detect_shells() }) - .await; - // `notify` so the strip re-renders and the dropdown closure - // captures the freshly landed list (nothing else is guaranteed to - // redraw an idle window). - let _ = this.update(cx, |app, cx| { - app.detected_shells = shells; - cx.notify(); - }); - }) - .detach(); + // Fill the "+" dropdown from the machine this window is bound to. + app.refresh_shells(cx); // Persist the session one last time as the app quits. This captures the // latest state — including a plain `cd` that changed a pane's cwd but // triggered no structural change — so the next launch restores where the @@ -1384,6 +1390,29 @@ impl Tty7App { /// Called after every structural change; the write is a small synchronous /// JSON dump and any error is swallowed inside `Session::save`. pub(crate) fn save_session(&self, cx: &mut App) { + // Tripwire for the write this record must never take: a pane created + // for one workspace being persisted under another. Each view remembers + // the workspace whose window created it; if that and the id this save + // records under have come apart, the window's tabs and its identity + // are describing two different workspaces — the exact corruption that + // once copied one workspace's whole layout into another's record and + // resumed its agents twice. Shout with everything a bug report needs; + // the save still runs, because refusing it would silently stop + // persisting the user's layout on the strength of one tripped check. + for view in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) { + let Some(owner) = view.read(cx).owner_workspace() else { + continue; + }; + if owner != self.workspace { + log::error!( + "save_session: window of workspace {} is recording pane {} \ + that was created for workspace {owner} — cross-workspace \ + write detected, please report this", + self.workspace, + view.read(cx).pane_id, + ); + } + } let tabs: Vec = self .tabs .iter() @@ -1447,7 +1476,7 @@ impl Tty7App { crate::ui::windows::refresh_menu(cx); } - /// Design §15's other half: a workspace's forwards belong to the workspace, + /// The other half: a workspace's forwards belong to the workspace, /// so stopping it has to end them — nothing else will. A pane's forwards /// need no equivalent; the daemon drops those with the pane. /// @@ -1601,10 +1630,19 @@ impl Tty7App { // The closed-tab stack is per *window* and survives a workspace swap, // so it is the one thing that could carry a tab across machines. self.rebind_host(previous_host, cx); + // So does the "+" dropdown's shell list — and unlike the closed stack it + // is rebuilt rather than dropped, from the machine now in front of us. + self.refresh_shells(cx); let font_size = self.font_size; let pane_ws = self.window_workspace(cx); - let (tabs, active) = - tabs_from_session(pane_ws.as_ref(), Some(session), font_size, window, cx); + let (tabs, active) = tabs_from_session( + pane_ws.as_ref(), + self.workspace, + Some(session), + font_size, + window, + cx, + ); self.tabs = tabs; self.active = active; self.maximized = None; @@ -1627,6 +1665,7 @@ impl Tty7App { let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(pane_ws.as_ref())); let Some(pane) = session_to_pane( pane_ws.as_ref(), + self.workspace, &st.pane, &alive, self.font_size, @@ -1904,8 +1943,14 @@ impl Tty7App { .get(this.workspace) .map(|w| w.session.clone()); let pane_ws = this.window_workspace(cx); - let (tabs, active) = - tabs_from_session(pane_ws.as_ref(), saved, font_size, window, cx); + let (tabs, active) = tabs_from_session( + pane_ws.as_ref(), + this.workspace, + saved, + font_size, + window, + cx, + ); this.tabs = tabs; this.active = active; } @@ -2503,7 +2548,7 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on); } - /// How a forward on `pane_id` reaches the daemon (design §15). + /// How a forward on `pane_id` reaches the daemon. /// /// Looked up across every tab's leaves rather than off the focused one: the /// Forwards band tracks the pane the *panel* is showing, which is not @@ -3087,7 +3132,7 @@ impl Tty7App { ) { let parts = match parts { Ok(parts) => parts, - // §17: the slot keeps its place and says what went wrong. It does + // The slot keeps its place and says what went wrong. It does // not collapse the split under the user, and it does not close the // tab — both would throw away a layout because a network blinked. Err(reason) => { @@ -3118,7 +3163,25 @@ impl Tty7App { // Whether the user was sitting on this pane while it connected. Read // before the swap, since the placeholder leaves the tree in it. let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx); + // The saved id was gone and this is a fresh shell in its cwd, so the + // agent that was running in it has to be resumed by hand — the same + // thing a local pane's restore does, deferred to here because only the + // machine could say whether the attach took. + let resume = (!parts.restored) + .then(|| { + let spawn = &pending.read(cx).spawn; + agent_resume_command( + &spawn.agent, + spawn.agent_session_id.as_deref(), + spawn.agent_launch_argv.as_deref(), + cx, + ) + }) + .flatten(); let view = build_terminal_view(parts, font_size, window, cx); + if let Some(cmd) = resume { + view.read(cx).run_command_line(&cmd); + } let slot = PaneSlot::Ready(view.clone()); self.tabs .iter_mut() @@ -3171,7 +3234,7 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - // A window is one machine (design §2). A remote workspace's window must + // A window is one machine. A remote workspace's window must // not open a shell on *this* computer, so the refusal happens before // anything is spawned rather than after a local pane is already in the // tab strip. @@ -3192,7 +3255,16 @@ impl Tty7App { .and_then(|leaf| leaf.read(cx).spawnable_cwd()) }); let pane_ws = self.window_workspace(cx); - let tab = match new_terminal(pane_ws, self.font_size, cwd, None, shell, window, cx) { + let tab = match new_terminal( + pane_ws, + Some(self.workspace), + self.font_size, + cwd, + None, + shell, + window, + cx, + ) { Ok(view) => view, Err(e) => { log::error!("new tab spawn failed: {e}"); @@ -3323,6 +3395,7 @@ impl Tty7App { let shell = target.read(cx).shell_spec(); match new_terminal( self.window_workspace(cx), + Some(self.workspace), self.font_size, cwd, None, @@ -3948,6 +4021,7 @@ impl Tty7App { }; let new = match new_terminal( self.window_workspace(cx), + Some(self.workspace), self.font_size, cwd, None, @@ -4195,6 +4269,7 @@ impl Tty7App { ) { let view = match new_terminal( self.window_workspace(cx), + Some(self.workspace), self.font_size, Some(wt.path), None, @@ -6181,10 +6256,10 @@ pub(crate) mod render_probe { } impl Tty7App { - /// Design §10's status strip, on a window that has tabs. + /// The status strip, on a window that has tabs. /// /// `ui::home` draws the same line on an *empty* remote window; this is the - /// one that matters, because §17's rule — a window that loses its machine + /// one that matters, because the rule — a window that loses its machine /// keeps showing what it had — only means anything when there is something /// to keep showing. Both read the same /// [`RemoteStatus::strip_message`](crate::ui::remote_workspace::RemoteStatus::strip_message), @@ -6246,7 +6321,7 @@ impl Tty7App { ) } - /// Design §10's bottom line: 未连接 — 输入暂不生效. + /// The bottom line: 未连接 — 输入暂不生效. /// /// It exists because the degrade is otherwise invisible. Everything a /// disconnected window *can* still do — scroll, select, copy, ⌘F — works @@ -6293,7 +6368,7 @@ impl Tty7App { } /// Which SSH connection a forward is established on: the pane's own, or — for a -/// remote workspace — the **workspace's** (design §15, M7). +/// remote workspace — the **workspace's** (M7). /// /// The same shape and the same reason as /// [`SftpRoute`](crate::ui::sftp::SftpRoute): resolved on the UI thread from the @@ -6367,7 +6442,7 @@ impl ForwardRoute { Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)) } - /// Drop every forward the workspace owns (design §15: they outlive the + /// Drop every forward the workspace owns (they outlive the /// panes, so something has to end them when the workspace does). /// /// A no-op on the pane arm, and that is correct rather than a gap: a pane's @@ -6507,7 +6582,7 @@ impl Render for Tty7App { }) // Native-SSH status strip / reconnect notice (E1/E4). .when_some(ssh_status, |this, el| this.child(el)) - // The remote *workspace*'s own state (design §10). A sibling of the + // The remote *workspace*'s own state. A sibling of the // SSH pane strip rather than a merge: that one is about one pane's // ssh process, this is about the machine the whole window is on, and // a window can legitimately show both. @@ -7051,6 +7126,25 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { } } +/// The command that puts a saved coding-agent conversation back, or `None` when +/// there is nothing to resume (no agent, no captured session id, the agent opts +/// out of sessions, or the user turned the feature off). +/// +/// Shared by the two places that learn a pane came back as a bare shell: session +/// restore, for a local leaf whose daemon already said the pane was gone, and +/// [`Tty7App::land_pane`], for a remote one where only the machine could say. +fn agent_resume_command( + agent: &Option, + session_id: Option<&str>, + launch_argv: Option<&[String]>, + cx: &App, +) -> Option { + if !cx.global::().restore_agent_sessions { + return None; + } + agent.as_ref()?.resume_command(session_id?, launch_argv) +} + /// Convert a live `Pane` tree into its serializable mirror, reading each /// leaf's current cwd and each split's axis + ratio. Used when saving. fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { @@ -7065,9 +7159,15 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { cwd: spawn.working_directory.clone(), pane_id: spawn.restore_pane, ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, + // Written back out rather than blanked. A remote pane spends + // its first seconds here, and any save landing in that window + // used to drop the agent this leaf was running — after which + // ending the workspace's sessions left nothing to resume from. + // The pane cannot be interrogated yet, but what it is being + // rebuilt *from* is right here. + agent: spawn.agent, + agent_session_id: spawn.agent_session_id.clone(), + agent_launch_argv: spawn.agent_launch_argv.clone(), } } Pane::Leaf(PaneSlot::Ready(view)) => { @@ -7122,10 +7222,11 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { } } -/// Set of pane ids currently alive **on `route`'s machine**, used by -/// `session_to_pane` to decide per leaf whether to re-`attach` or `spawn`. -/// Computed once per restore from that daemon's `List`; empty (→ all-fresh) -/// when it is unreachable. +/// The pane ids currently alive **on `route`'s machine**, each with the +/// workspace that owns it (when the daemon knows — `None` for panes spawned by +/// builds/daemons that predate `pane-owner`). Used by `session_to_pane` to +/// decide per leaf whether to re-`attach` or `spawn`. Computed once per restore +/// from that daemon's `List`; empty (→ all-fresh) when it is unreachable. /// /// There is deliberately no unrouted sibling. Pane ids are **per daemon**: /// asking this machine's daemon which of a remote workspace's ids are alive @@ -7135,7 +7236,9 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { /// unrouted answer now go through /// [`pane_liveness`](crate::terminal::pane_liveness), which cannot spell the /// question without naming a machine. -pub(crate) fn alive_panes_on(route: &crate::terminal::PaneRoute) -> std::collections::HashSet { +pub(crate) fn alive_panes_on( + route: &crate::terminal::PaneRoute, +) -> std::collections::HashMap> { // **Local routes only.** This is a *blocking* `List`, and every caller is on // the UI thread — which is fine against a socket on this machine and is a // multi-second window freeze against one that has to open an SSH channel @@ -7144,15 +7247,46 @@ pub(crate) fn alive_panes_on(route: &crate::terminal::PaneRoute) -> std::collect // falls back to a fresh pane when the id is gone, which is exactly what // this set was being consulted for. if !matches!(route, crate::terminal::PaneRoute::Local) { - return std::collections::HashSet::new(); + return std::collections::HashMap::new(); } crate::terminal::RemoteTerminal::list_panes_on(route) .into_iter() .filter(|p| p.alive) - .map(|p| p.pane_id) + .map(|p| (p.pane_id, p.owner)) .collect() } +/// Whether `owner`'s window may re-attach the live pane `id` — the ownership +/// gate on session restore. +/// +/// The failure this closes: two workspace records claiming one pane id (a +/// corrupted `session.json`), or a stale id landing on an unrelated pane after +/// the numbers were reused. Before the daemon knew owners, both cases attached +/// — one workspace's window silently picked up another's shell, which is how +/// `work`'s seven tabs once ended up duplicated into `personal`. A pane with no +/// recorded owner (older daemon, legacy spawn) stays attachable by anyone — +/// that is today's behavior, not a new risk. +fn pane_attachable( + alive: &std::collections::HashMap>, + id: u64, + owner: crate::core::session::WorkspaceId, +) -> bool { + match alive.get(&id) { + None => false, + Some(None) => true, + Some(Some(recorded)) => { + let ours = *recorded == owner.to_string(); + if !ours { + log::warn!( + "restore: pane {id} is owned by workspace {recorded}, not {owner}; \ + spawning fresh instead of attaching to it" + ); + } + ours + } + } +} + /// Rebuild the tab list from a persisted `Session`, re-attaching to still-live /// daemon panes where possible and spawning fresh shells otherwise. An absent or /// empty session yields no tabs (the home page). Shared by first-launch restore @@ -7160,6 +7294,7 @@ pub(crate) fn alive_panes_on(route: &crate::terminal::PaneRoute) -> std::collect /// stay in lockstep. fn tabs_from_session( workspace: Option<&crate::terminal::PaneWorkspace>, + owner: WorkspaceId, session: Option, font_size: f32, window: &mut Window, @@ -7175,7 +7310,8 @@ fn tabs_from_session( for st in &session.tabs { // A tab whose every leaf failed to come back has nothing to show; drop // it rather than restore an empty frame (or, worse, abort the launch). - let Some(pane) = session_to_pane(workspace, &st.pane, &alive, font_size, window, cx) else { + let Some(pane) = session_to_pane(workspace, owner, &st.pane, &alive, font_size, window, cx) + else { log::error!("dropping a restored tab: no pane in it could be started"); continue; }; @@ -7225,8 +7361,9 @@ fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: boo /// nodes — which every tree operation ignores — in a live tab. fn session_to_pane( workspace: Option<&crate::terminal::PaneWorkspace>, + owner: WorkspaceId, sp: &SessionPane, - alive: &std::collections::HashSet, + alive: &std::collections::HashMap>, font_size: f32, window: &mut Window, cx: &mut Context, @@ -7253,7 +7390,7 @@ fn session_to_pane( // the attempt to attach happens off the UI thread, where a dead // id costs one failed round trip and falls back to a spawn. true => (*pane_id).filter(|_| same_daemon), - false => (*pane_id).filter(|id| same_daemon && alive.contains(id)), + false => (*pane_id).filter(|id| same_daemon && pane_attachable(alive, *id, owner)), }; // A *dead* native-SSH leaf (spec persisted, pane no longer alive) // reconnects rather than dropping back to a local shell (FR-C2/E4): @@ -7274,6 +7411,7 @@ fn session_to_pane( // must respawn comes back on the default shell. let view = match new_terminal( workspace.cloned(), + Some(owner), font_size, cwd.clone(), restore, @@ -7293,17 +7431,37 @@ fn session_to_pane( // where it left off (cmux's auto-resume, config-gated). The bytes // sit in the PTY input queue until the shell reads its first // command — same mechanism as tmux send-keys at spawn. - if restore.is_none() - && cx.global::().restore_agent_sessions - && let (Some(agent), Some(id)) = (agent, agent_session_id) - && let Some(cmd) = agent.resume_command(id, agent_launch_argv.as_deref()) - // A pane whose terminal has not arrived yet cannot be sent a - // resume command. It does not need one either: `restore_pane` - // travelled with the spawn, so what lands is the *same* agent - // pane, still running its conversation. - && let Some(terminal) = view.terminal() - { - terminal.read(cx).run_command_line(&cmd); + match &view { + // A local leaf already knows the answer, and the *view* is what + // knows it. Not `restore.is_none()`: `alive` is read once at the + // top of the restore, so a pane that exits between that `List` + // and this attach fails into a fresh shell inside + // `spawn_shell_terminal_in` — which used to land here as + // "restore.is_some(), so it kept its agent", leaving an empty + // shell and a conversation nobody resumed. + PaneSlot::Ready(terminal) if !terminal.read(cx).restored() => { + if let Some(cmd) = agent_resume_command( + agent, + agent_session_id.as_deref(), + agent_launch_argv.as_deref(), + cx, + ) { + terminal.read(cx).run_command_line(&cmd); + } + } + PaneSlot::Ready(_) => {} + // A remote leaf does not know yet whether its id was still + // good — the attach is happening on a background thread. The + // agent travels with the attempt and `land_pane` decides. This + // is also what keeps a save landing mid-connect from erasing + // it (see `pane_to_session`). + PaneSlot::Connecting(pending) => { + pending.update(cx, |pending, _| { + pending.spawn.agent = *agent; + pending.spawn.agent_session_id = agent_session_id.clone(); + pending.spawn.agent_launch_argv = agent_launch_argv.clone(); + }); + } } Some(Pane::leaf(view)) } @@ -7315,8 +7473,8 @@ fn session_to_pane( // One side failing collapses the split onto the survivor, exactly // as closing that pane by hand would. match ( - session_to_pane(workspace, a, alive, font_size, window, cx), - session_to_pane(workspace, b, alive, font_size, window, cx), + session_to_pane(workspace, owner, a, alive, font_size, window, cx), + session_to_pane(workspace, owner, b, alive, font_size, window, cx), ) { (Some(a), Some(b)) => Some(Pane::split_node(axis, *ratio, a, b)), (Some(only), None) | (None, Some(only)) => Some(only), @@ -7337,8 +7495,14 @@ fn session_to_pane( /// to the same machine so everything pane-addressed afterwards (`Kill`, the /// restore `List`, a reconnect's `Attach`) goes back to it. `None` is a local /// pane, byte-for-byte what it always was. +/// `owner` is the workspace whose window this pane is being created for — +/// recorded daemon-side at spawn (so restore can tell whose pane is whose) and +/// stamped on the view (so `save_session` can shout if a window's tabs and its +/// identity ever come apart). `None` only for callers that genuinely have no +/// workspace (tests). fn new_terminal( workspace: Option, + owner: Option, font_size: f32, working_directory: Option, restore_pane: Option, @@ -7363,6 +7527,7 @@ fn new_terminal( working_directory, restore_pane, shell, + owner, )?; return Ok(PaneSlot::Ready(build_terminal_view( parts, font_size, window, cx, @@ -7375,6 +7540,13 @@ fn new_terminal( working_directory, restore_pane, shell, + // Filled in by session restore, the only caller with an agent session + // to bring back (see `session_to_pane`). A brand-new tab or split has + // no conversation behind it. + agent: None, + agent_session_id: None, + agent_launch_argv: None, + owner, font_size, }; // The machine as the user knows it. `RemoteTarget`'s `Display` is the same @@ -7412,32 +7584,22 @@ fn start_pane_spawn( let parts = cx .background_executor() .spawn(async move { - let attempt = |restore| { - TerminalView::spawn_shell_terminal_in( - spawn.workspace.clone(), - spawn.working_directory.clone(), - restore, - spawn.shell.clone(), - ) - }; - match spawn.restore_pane { - // Restore, on a machine nobody asked "which panes are still - // alive?" — because asking is itself a routed round trip and - // the UI thread is where that question used to be asked from - // (`alive_panes_on`). Trying the attach *is* the question, - // and a failed one costs one round trip on a connection this - // pane needed open anyway. - // - // Falling back to a fresh pane rather than surfacing the - // error: an id that is gone is the ordinary case after the - // remote's daemon has restarted, and a pane the user cannot - // get back is not worth a slot that only offers "Try Again". - Some(id) => attempt(Some(id)).or_else(|e| { - log::info!("pane {id} is gone on its machine ({e:#}); spawning fresh"); - attempt(None) - }), - None => attempt(None), - } + // Restore, on a machine nobody asked "which panes are still + // alive?" — because asking is itself a routed round trip and + // the UI thread is where that question used to be asked from + // (`alive_panes_on`). Trying the attach *is* the question, and a + // failed one falls back to a fresh pane inside + // `spawn_shell_terminal_in`: an id that is gone is the ordinary + // case after the workspace's sessions were ended, and a pane the + // user cannot get back is not worth a slot that only offers + // "Try Again". + TerminalView::spawn_shell_terminal_in( + spawn.workspace.clone(), + spawn.working_directory.clone(), + spawn.restore_pane, + spawn.shell.clone(), + spawn.owner, + ) // Flattened to a string here rather than carried as an // `anyhow::Error`: the chain is not `Send` across this await in // a form worth keeping, and what the pane shows is the rendered @@ -7477,6 +7639,17 @@ fn build_terminal_view( app.on_child_exited(view.clone(), window, cx); }) .detach(); + // The pane's agent started (or replaced) a conversation. Persist it: the + // session id is what a later restore resumes from, and nothing else was + // making the window save between the id arriving and the user acting. + cx.subscribe_in( + &view, + window, + |app, _view, _: &crate::terminal::view::AgentSessionChanged, _window, cx| { + app.save_session(cx); + }, + ) + .detach(); // Native-SSH auth/host-key prompts raised by this pane → in-pane sheet. Same // single build site as ChildExited, so every pane (new tab, split, restore) // is covered. @@ -8010,10 +8183,43 @@ mod window_drag_tests { #[cfg(test)] mod tests { use super::{ - TabAgentSession, leaf_shares_the_window_daemon, parse_ssh_connect_input, + TabAgentSession, leaf_shares_the_window_daemon, pane_attachable, parse_ssh_connect_input, parse_ssh_option_words, }; + /// The restore-side ownership gate. A pane owned by another workspace must + /// read as unattachable even while alive — attaching is how one + /// workspace's saved ids once silently picked up another's shells (and + /// their running agents). A pane with no recorded owner stays attachable + /// by anyone: that is the pre-`pane-owner` behavior, and refusing it would + /// orphan every pane a legacy daemon is holding. + #[test] + fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() { + let ours = crate::core::session::WorkspaceId::new(); + let theirs = crate::core::session::WorkspaceId::new(); + let alive: std::collections::HashMap> = [ + (1, Some(ours.to_string())), + (2, Some(theirs.to_string())), + (3, None), + ] + .into_iter() + .collect(); + + assert!(pane_attachable(&alive, 1, ours), "our own pane attaches"); + assert!( + !pane_attachable(&alive, 2, ours), + "another workspace's pane must spawn fresh instead" + ); + assert!( + pane_attachable(&alive, 3, ours), + "an unowned pane is legacy" + ); + assert!( + !pane_attachable(&alive, 4, ours), + "a dead id never attaches" + ); + } + /// A remote window's saved layout can hold a native-SSH pane, whose russh /// session runs in *this* client's daemon rather than the machine's. Its /// saved id must not be matched against the remote's pane list: the two @@ -8057,6 +8263,56 @@ mod tests { ); } + /// **A pane that is still connecting keeps the agent it is being rebuilt + /// from.** Every remote pane spends its first seconds in that state, and a + /// save landing in the window — a focus change, a resize, the window + /// closing — used to write `agent: null` over the record. Ending that + /// workspace's sessions afterwards left nothing to resume *from*, which is + /// what made the resume look like it worked only sometimes. + #[gpui::test] + fn a_connecting_pane_saves_the_agent_it_is_rebuilding(cx: &mut gpui::TestAppContext) { + use crate::core::cli_agent::CLIAgent; + use crate::core::session::SessionPane; + use crate::ui::pane::{Pane, PaneSlot}; + use crate::ui::pending_pane::{PendingPane, PendingSpawn}; + use gpui::AppContext as _; + + cx.update(|cx| { + let pending = cx.new(|cx| { + PendingPane::new( + "build-box", + PendingSpawn { + workspace: None, + working_directory: Some(std::path::PathBuf::from("/work")), + restore_pane: Some(7), + shell: None, + agent: Some(CLIAgent::Claude), + agent_session_id: Some("sid-abc".to_string()), + agent_launch_argv: Some(vec!["claude".to_string()]), + owner: None, + font_size: 14.0, + }, + cx, + ) + }); + let saved = super::pane_to_session(&Pane::leaf(PaneSlot::Connecting(pending)), cx); + let SessionPane::Leaf { + pane_id, + agent, + agent_session_id, + agent_launch_argv, + .. + } = saved + else { + panic!("a leaf saves as a leaf"); + }; + assert_eq!(pane_id, Some(7), "the id it is re-attaching to"); + assert_eq!(agent, Some(CLIAgent::Claude)); + assert_eq!(agent_session_id.as_deref(), Some("sid-abc")); + assert_eq!(agent_launch_argv, Some(vec!["claude".to_string()])); + }); + } + #[test] fn parses_ssh_option_words_with_quotes() { assert_eq!( @@ -8329,3 +8585,132 @@ mod keybinding_gpui_tests { assert_eq!(recording, Some(false)); } } + +/// The "+" dropdown is a property of the window's *machine* — the whole of +/// [`Tty7App::refresh_shells`]'s reason to exist. +#[cfg(test)] +mod shell_menu_gpui_tests { + use crate::core::config::Config; + use crate::core::session::{ + RemoteRef, RemoteTarget, Session, Workspace, WorkspaceId, WorkspaceStore, Workspaces, + }; + use crate::ui::app::Tty7App; + use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; + + fn harness(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + // The window's construction persists a session; without this it would + // write the developer's real one. + crate::core::config::pin_test_config_dir(); + // The shell probe runs on `HostOps`' own thread pool, off gpui's + // executor, so waiting for it parks the test thread. + cx.executor().allow_parking(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + crate::ui::keymap::init(cx); + // Switching workspaces rebinds the window's registry entry; the + // headless harness opens windows directly, so nothing else installs + // it. Empty is the truth here — this window was never registered. + crate::ui::windows::WindowRegistry::init(cx); + }); + let window = cx.add_window(|window, cx| { + let app = + cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); + gpui_component::Root::new(app, window, cx) + }); + let app = window + .update(cx, |root, _, _| { + root.view() + .clone() + .downcast::() + .ok() + .expect("window root wraps a Tty7App") + }) + .unwrap(); + let vcx = VisualTestContext::from_window(window.into(), cx); + (app, vcx) + } + + /// Pump both executors until `done`, or give up. The probe crosses back from + /// a real thread pool, so `run_until_parked` alone has nothing to wait on. + fn pump_until( + app: &Entity, + vcx: &mut VisualTestContext, + done: impl Fn(&Tty7App) -> bool, + ) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + vcx.background_executor.run_until_parked(); + if app.update(vcx, |app, _| done(app)) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + /// A local window fills its menu from this computer, as it always has. + #[gpui::test] + fn a_local_window_lists_this_computers_shells(cx: &mut TestAppContext) { + let (app, mut vcx) = harness(cx); + assert!( + pump_until(&app, &mut vcx, |app| !app.shells.shells.is_empty()), + "the local probe never landed" + ); + app.update(&mut vcx, |app, _| { + assert!(app.shells_host.is_local()); + assert!( + !app.shells.default_name.is_empty(), + "the menu has no default to tag" + ); + }); + } + + /// **The bug this exists to stop.** A window bound to a machine that isn't + /// answering offers *nothing* rather than this computer's shells: every one + /// of those rows would spawn a path that only exists here (`/bin/zsh` on a + /// box whose zsh is `/usr/bin/zsh`), and the pane would come up as a spawn + /// failure. The empty list falls back to the plain "New Tab" entry, which + /// the far end resolves with its own default shell. + #[gpui::test] + fn an_unreachable_remote_window_offers_no_local_shells(cx: &mut TestAppContext) { + let (app, mut vcx) = harness(cx); + assert!( + pump_until(&app, &mut vcx, |app| !app.shells.shells.is_empty()), + "the local probe never landed" + ); + + // A workspace on a machine nothing in this process has connected to. + let remote = Workspace::on_remote(RemoteRef::new( + RemoteTarget::Alias { + alias: "build-box".into(), + }, + WorkspaceId::new(), + )); + let remote_id = remote.id; + app.update_in(&mut vcx, |app, window, cx| { + WorkspaceStore::install_for_test( + cx, + Workspaces { + workspaces: vec![remote], + active: None, + }, + ); + app.switch_workspace(remote_id, window, cx); + }); + + assert!( + pump_until(&app, &mut vcx, |app| !app.shells_host.is_local()), + "the window never rebound to the remote machine" + ); + app.update(&mut vcx, |app, _| { + assert!( + app.shells.shells.is_empty(), + "a remote window must not offer this computer's shells: {:?}", + app.shells.shells + ); + }); + } +} diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 5e5a0724..b18bb06e 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -357,7 +357,7 @@ fn classify_external_change( /// What a landed write does to the buffer it wrote. /// /// Separated for the same reason: this is the three-way answer that the ⌘S -/// exemption in contract §1 turns on, and it is pure. +/// exemption turns on, and it is pure. #[derive(Debug, PartialEq, Eq)] struct SaveLanding { /// The buffer still holds what reached disk, so it may be marked clean. @@ -806,7 +806,7 @@ impl Tty7App { /// Write one buffer back to its path, optionally closing it once the write /// lands. /// - /// The write is asynchronous (contract §1 exempts this): ⌘S no longer + /// The write is asynchronous (an explicit exemption): ⌘S no longer /// blocks the UI thread, so the dirty marker clears a frame later rather /// than instantly. Three things that costs us, and how each is paid: /// @@ -1514,7 +1514,7 @@ mod tests { Some(MTime { secs, nanos }) } - /// M2 regression guard (contract §10.5): the save → external-change → + /// M2 regression guard: the save → external-change → /// reload states still decide correctly now that the write is asynchronous. #[test] fn external_changes_are_told_apart_from_our_own_saves() { @@ -1561,7 +1561,7 @@ mod tests { ); } - /// M2 regression guard (contract §1, the ⌘S exemption): the three things + /// M2 regression guard (the ⌘S exemption): the three things /// asynchronous saving has to get right. #[test] fn a_landed_save_only_cleans_a_buffer_that_did_not_move() { diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 09475437..18a91978 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -874,7 +874,7 @@ fn optimistic_write( /// leaves the tree showing pre-change content indefinitely, since nothing is /// left in flight or marked stale to correct it. Discarding cannot be wrong: /// the host is the authority, the next paint asks it, and the row vanishing on -/// failure is exactly what contract §1 says an optimistic write costs. +/// failure is exactly what an optimistic write costs. /// /// `before` is taken by value so the snapshot is consumed rather than left /// lying around for a caller to misuse. @@ -909,7 +909,7 @@ impl Tty7App { /// [`spawn_host`](Tty7App::spawn_host) resolved to its host object. /// /// **Derived from the window's workspace, never cached.** A window shows one - /// workspace and a workspace names one machine (design §3), so there is a + /// workspace and a workspace names one machine, so there is a /// right answer at every instant and no event to subscribe to — a tab /// switch, a new split, a rebind and a session restore all move it by /// construction. An earlier version of this re-derived the id from the @@ -944,7 +944,7 @@ impl Tty7App { None => Vec::new(), }; // Only panes on the window's own machine contribute. They all are, by - // design §3 — but a stray one (a tab carried across a rebind) would put + // design — but a stray one (a tab carried across a rebind) would put // a path from another machine into the root set, and every listing of it // would then be asked of the wrong host. // @@ -2434,7 +2434,7 @@ mod tests { let host = tty7_core::host::local::LocalHost::new(); // The fixture is built through the host too. Partly because it is the // thing under test and partly because it keeps this module honest: the - // §10.6 grep that forbids direct filesystem calls in `src/ui` does not + // CI grep that forbids direct filesystem calls in `src/ui` does not // know test modules from production code, and it should not have to. let tmp = std::env::temp_dir().join(format!("tty7-tree-host-{}", std::process::id())); let _ = host.remove(&tmp, true); @@ -2556,7 +2556,7 @@ mod tests { let _ = host.remove(&tmp, true); } - /// M2 regression guard (contract §10.5): a create, a rename and a delete + /// M2 regression guard: a create, a rename and a delete /// each show their result before the host has confirmed it, and a failure /// leaves the directory to relist rather than showing a row for a file that /// does not exist. diff --git a/src/ui/home.rs b/src/ui/home.rs index becce303..94583b55 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -247,12 +247,12 @@ impl Tty7App { ) } - // ----- connect to another machine (design §10) -------------------------- + // ----- connect to another machine -------------------------- /// The status strip a remote window wears when it is not attached. /// - /// Design §10 puts one at the top of the window in every state that is not - /// `Attached`, and §17 is why: a window that has lost its machine must keep + /// One sits at the top of the window in every state that is not + /// `Attached`, and this is why: a window that has lost its machine must keep /// showing what it had and say so, rather than close or empty itself. A /// local window and a healthy remote one say nothing — a permanent "you are /// fine" banner is noise. @@ -263,7 +263,7 @@ impl Tty7App { let machine = self.remote_machine_label(cx); let status = self.remote_status(cx)?; let message = status.strip_message(&machine)?; - // §17: a failure state is a resting state, so it always offers the next + // A failure state is a resting state, so it always offers the next // move. The button belongs here and not only on a window with tabs — // this is the *empty* remote window, which is precisely the one with no // other way out. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 27133b5d..b0a2c9a5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,8 +13,8 @@ pub mod file_tree; pub mod forwards; pub mod hints; pub mod home; -// The `Host` layer's GUI half (`docs/2026-07-27-remote-workspace-impl-contract.md` -// §5). The facade and the registry land ahead of the call sites that consume +// The `Host` layer's GUI half. The facade and the registry land ahead of the +// call sites that consume // them — the six views move over to `HostOps` as a separate change — so they // read as dead code until that merges. #[allow(dead_code)] diff --git a/src/ui/pending_pane.rs b/src/ui/pending_pane.rs index 5991ba79..3a8180b3 100644 --- a/src/ui/pending_pane.rs +++ b/src/ui/pending_pane.rs @@ -55,6 +55,28 @@ pub struct PendingSpawn { pub working_directory: Option, pub restore_pane: Option, pub shell: Option, + /// The coding agent this leaf was last seen running, its native session id + /// and the argv it was launched with — carried verbatim from the saved + /// session. + /// + /// Two jobs, both of which need the *fields* rather than a precomputed + /// resume line: + /// + /// 1. If `restore_pane` turns out to be gone, `land_pane` builds the + /// `--resume` command from them. Whether it is needed is only known on + /// the machine, one round trip away. + /// 2. A save that happens while this pane is still connecting writes them + /// straight back out ([`pane_to_session`](crate::ui::app)). Without + /// that, every such save silently erased the agent from the record — + /// and a workspace whose sessions were then ended had nothing left to + /// resume *from*, which is what made the resume look intermittent. + pub agent: Option, + pub agent_session_id: Option, + pub agent_launch_argv: Option>, + /// The workspace this pane is being created for — carried so the spawn that + /// finally lands (and any retry) names the same owner the synchronous local + /// path would have. + pub owner: Option, /// Inherited by the terminal this becomes, so a pane that arrives late /// still matches the ones already on screen. pub font_size: f32, @@ -64,7 +86,7 @@ pub struct PendingSpawn { pub enum PendingState { Connecting, /// The attempt failed, with the reason as the user should read it. A - /// resting state (design §17): the slot keeps its place in the layout and + /// resting state: the slot keeps its place in the layout and /// offers the next move rather than collapsing the split under the user. Failed(SharedString), } @@ -167,7 +189,7 @@ impl Render for PendingPane { .text_color(theme.foreground) .child(format!("Couldn't reach {}", self.machine)), ) - // The hop that gave up, in full. §17: a failure says which of + // The hop that gave up, in full: a failure says which of // "the daemon isn't running", "that machine refused us" and // "the server over there is too old" it was, because they want // completely different things from the user. diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index 13645936..e1208b40 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -1,4 +1,4 @@ -//! "Connect to Host": the client half of a remote workspace (design §10, §12). +//! "Connect to Host": the client half of a remote workspace. //! //! This module is everything between *the user picked a machine* and *the window //! is bound to a workspace on it*. It has no gpui views of its own — the home @@ -19,7 +19,7 @@ //! //! ## Machines are configured once //! -//! Design §2: a remote workspace reuses an SSH configuration that already +//! A remote workspace reuses an SSH configuration that already //! exists — a saved profile or a `~/.ssh/config` alias — with its keys, its jump //! host and its `ProxyCommand` already set up. There is deliberately no host //! *editor* here; [`available_hosts`] only reads, and [`spec_for`] hands the @@ -48,7 +48,8 @@ use crate::core::config::Config; use crate::core::session::{RemoteTarget, WorkspaceId}; use crate::daemon::control::{ControlHello, ControlRequest, ReplyOk}; use crate::daemon::install::{ - InstallConfirm, InstallDecision, InstallRequest, MismatchedRemoteDaemon, + InstallConfirm, InstallDecision, InstallPhase, InstallProgress, InstallRequest, + MismatchedRemoteDaemon, }; use crate::daemon::protocol::{AuthPromptKind, AuthResponse, NativeSshSpec}; use crate::daemon::router::RouteHeader; @@ -292,7 +293,7 @@ pub fn control_route(target: &RemoteTarget, cx: &App) -> Result, - /// The remote's `$HOME` — where a *new* workspace starts (design §10). The + /// The remote's `$HOME` — where a *new* workspace starts. The /// remote's, never this client's. pub home: PathBuf, pub rows: Vec, @@ -409,7 +410,7 @@ pub fn list_workspaces(host: &Arc) -> io::Result String { uuid::Uuid::new_v4().to_string() @@ -522,7 +523,7 @@ impl RemoteConnections { } /// Where a *new* workspace on `id` would start: that machine's own `$HOME`, - /// never this client's (design §10). + /// never this client's. pub fn home(cx: &mut App, id: HostId) -> Option { cx.default_global::() .homes @@ -558,7 +559,7 @@ impl RemoteConnections { } } -/// Push a workspace's layout to the machine that owns it (design §10: the +/// Push a workspace's layout to the machine that owns it (the /// remote's `workspaces.json` is the authority). Blocking. pub fn put_remote_layout(host: &Arc, key: String, record: Value) -> io::Result<()> { host.client() @@ -572,7 +573,7 @@ pub fn put_remote_layout(host: &Arc, key: String, record: Value) -> /// Pull one workspace's authoritative record from the machine that owns it. /// Blocking. /// -/// The read side of design §10's split, and what a +/// The read side of the split, and what a /// [`ControlEvent::WorkspaceChanged`](crate::daemon::control::ControlEvent) /// asks for: the event says only *that* a record moved, so the record itself is /// fetched rather than carried. `ErrorKind::NotFound` is a real answer — the @@ -599,7 +600,7 @@ pub fn delete_remote_workspace(host: &Arc, key: String) -> io::Resul } // --------------------------------------------------------------------------- -// 6. Install consent (design §12, §16) +// 6. Install consent // --------------------------------------------------------------------------- /// The prompt shown before tty7 writes a binary onto someone else's machine. @@ -639,7 +640,11 @@ pub fn install_title(request: &InstallRequest) -> String { /// Bytes as the user thinks of them. Binary units with one decimal, matching the /// download sizes shown elsewhere in the app. -fn human_bytes(n: u64) -> String { +/// +/// Shared with the switcher's install bar so the size quoted in the consent +/// prompt and the size counting up underneath it are formatted identically — +/// they are the same number, and "8.2 MiB" beside "8.2 MB" would look like two. +pub fn human_bytes(n: u64) -> String { const KIB: f64 = 1024.0; let n = n as f64; if n < KIB { @@ -705,12 +710,69 @@ impl InstallConfirm for GuiInstallConfirm { } } +/// The latest progress report, per machine. +/// +/// Keyed by [`HostId`] rather than by the label the user typed, because the +/// string the installer reports is a *daemon-side* connection key +/// (`install::connection_label`) — the same one a relayed mismatch carries, and +/// the same one [`origin_host`] exists to translate. An alias like `java` never +/// reaches that side. +/// +/// Several machines can be installing at once (two windows, two connects), so +/// this is a map and not a slot. [`clear_install_progress`] drops an entry as +/// soon as its connect settles. +static PROGRESS: Mutex> = Mutex::new(Vec::new()); + +/// The progress sink the GUI registers ([`register`]). +/// +/// Called from whichever thread is moving bytes — the routed connection's +/// reader, in the normal case — so it does nothing but overwrite the machine's +/// slot. The panel picks it up on the poll it already runs while a connect is in +/// flight (`watch_for_install_consent`), which is what keeps a burst of reports +/// from becoming a burst of repaints. +pub struct GuiInstallProgress; + +impl InstallProgress for GuiInstallProgress { + fn report(&self, host: &str, phase: InstallPhase) { + // Same fallback as the auth relay's: a key this client never noted an + // origin for still resolves to a stable id, so an install is never + // silently unattributable. + let id = origin_host(host).unwrap_or_else(|| HostId::from_connection_key(host)); + let Ok(mut slots) = PROGRESS.lock() else { + return; + }; + match slots.iter_mut().find(|(known, _)| *known == id) { + Some(slot) => slot.1 = phase, + None => slots.push((id, phase)), + } + } +} + +/// What `host` last reported, if it is installing right now. +pub fn install_progress_for(host: HostId) -> Option { + let slots = PROGRESS.lock().ok()?; + slots + .iter() + .find(|(known, _)| *known == host) + .map(|(_, phase)| *phase) +} + +/// Forget a machine's progress. Called when a connect settles either way: on +/// success the install is over, and on failure the error takes the same space +/// the bar was using. +pub fn clear_install_progress(host: HostId) { + if let Ok(mut slots) = PROGRESS.lock() { + slots.retain(|(known, _)| *known != host); + } +} + /// Install the GUI's consent handler. Called once at startup; without it the /// process-wide default declines every install, which is deliberate — a tty7 /// with no UI attached must not decide on the user's behalf that writing to /// their servers is fine. pub fn register(cx: &mut App) { crate::daemon::install::set_install_confirm(Arc::new(GuiInstallConfirm)); + crate::daemon::install::set_install_progress(Arc::new(GuiInstallProgress)); crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth)); // Touch the globals so the first connect isn't also the first allocation of // the table it writes into, on a thread that is holding a socket open. @@ -773,7 +835,7 @@ struct RouteOrigin { /// /// **Why a table and not the connecting thread.** A question raised while a /// routed connection is being set up has to be attributed to a machine: the -/// sheet names it, the start-up queue is keyed by it (design §10, D7), and +/// sheet names it, the start-up queue is keyed by it (D7), and /// `raise_auth_sheet` finds a window with it. That used to be read off a /// thread-local set by [`connect_blocking`], which held for the workspace /// connect and quietly did not for a pane's — `connect_routed` lives in @@ -884,7 +946,7 @@ pub fn take_pending_auth() -> Option { } // --------------------------------------------------------------------------- -// 7. Remote daemon version skew (design §12) +// 7. Remote daemon version skew // --------------------------------------------------------------------------- /// The keep-or-restart question for a remote `tty7-server` at a different build. @@ -926,7 +988,7 @@ pub fn mismatch_target(m: &MismatchedRemoteDaemon) -> Option { origin_target(&m.host) } -/// Carry out design §12's "Restart Server": stop the `tty7-server` on the +/// Carry out "Restart Server": stop the `tty7-server` on the /// machine `header` names and start this client's build. **Blocking**, and /// **every pane that server hosts dies** — only ever call this with the user's /// explicit answer behind it. @@ -969,7 +1031,7 @@ mod tests { } } - /// §12 is explicit that the confirmation says *what* is written, *where*, + /// The confirmation says *what* is written, *where*, /// *how big* and *where from*. A field silently dropped from the prompt /// would turn an informed decision back into a blind one, so every one of /// them is pinned here rather than eyeballed. diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 66cb4ce1..9d8e8c38 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -1,4 +1,4 @@ -//! The window's half of "Connect to Host" (design §2, §10, §12). +//! 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 @@ -7,7 +7,7 @@ //! //! ## One window, one machine //! -//! Design §2 has a single rule and design §3 lists its inverse under *never do +//! There is a single rule, and its inverse is listed under *never do //! this*: **a window shows one workspace on one machine, and every tab and pane //! in it is on that machine.** The whole M5 data layer leans on it — a workspace //! stores its `host` once rather than per pane, and `sidebar_group` stays a bare @@ -28,11 +28,11 @@ //! ## What is left for M6 //! //! The flow below reaches `Attached` and stops. Reconnect backoff, takeover -//! (`Preempted`) and the start-up auth queue are M6 (contract §12), and the +//! (`Preempted`) and the start-up auth queue are M6, and the //! seams for them are named where they belong: [`RemoteStatus`] has the two //! states to add, [`Tty7App::connect_remote_workspace`] is the one entry point //! that opens a connection, and [`Tty7App::reopen_remote_at_startup`] is where -//! design §10's "`open: true` remote workspaces reconnect at launch" hooks in. +//! the "`open: true` remote workspaces reconnect at launch" rule hooks in. use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -51,7 +51,7 @@ use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow}; /// Where the home page's "Connect to Host" flow has got to. /// -/// Every state is a place the user can be *left*: design §17 is explicit that no +/// Every state is a place the user can be *left*: no /// failure closes a window, so [`ConnectFlow::Failed`] is a resting state with /// its own affordances rather than a toast on the way back to the picker. pub enum ConnectFlow { @@ -59,7 +59,7 @@ pub enum ConnectFlow { /// the panel shows while it runs. Connecting { choice: HostChoice }, /// It did not work, and the window stays here until the user decides what to - /// do about it (§17: never auto-close, always offer the next move). + /// do about it (never auto-close, always offer the next move). Failed { choice: HostChoice, error: String }, } @@ -76,7 +76,7 @@ impl ConnectFlow { } } -/// A **remote** workspace's connection state (design §10's machine). +/// A **remote** workspace's connection state. /// /// Only remote workspaces have one — a local window is not a disconnected /// remote window, it is a window with no machine to be connected to, which is @@ -95,13 +95,13 @@ pub enum RemoteStatus { Attached, /// The link dropped and the supervisor is retrying, for ever, on the /// backoff in [`Backoff`]. Read-only, and the window stays open — design - /// §10 is explicit that no failure closes a window. + /// No failure closes a window. Reconnecting { /// How many attempts have already failed. Shown because "reconnecting…" /// that has been on screen for four minutes should say so. attempt: u32, }, - /// Somebody else attached to this workspace (design §10, D8). Read-only and + /// Somebody else attached to this workspace (D8). Read-only and /// **not** retried: a client that reconnected automatically would fight the /// machine the user just moved to. Taking it back is a deliberate act. Preempted { @@ -134,7 +134,7 @@ impl RemoteStatus { } } - /// The line along the bottom of a degraded window (design §10: 底部一条 + /// The line along the bottom of a degraded window (底部一条 /// "未连接 — 输入暂不生效"). /// /// Separate from [`RemoteStatus::strip_message`] because they answer @@ -151,7 +151,7 @@ impl RemoteStatus { } /// What the status strip's button offers, or `None` when there is nothing - /// useful to do. §17: a failure state always offers the next move. + /// useful to do: a failure state always offers the next move. pub fn action_label(&self) -> Option<&'static str> { match self { RemoteStatus::Attached | RemoteStatus::Connecting => None, @@ -164,7 +164,7 @@ impl RemoteStatus { } } - /// Whether keystrokes reach the panes. Design §10's read-only degrade: a + /// Whether keystrokes reach the panes. The read-only degrade: a /// window that is not attached still scrolls, selects, copies and searches, /// but typing goes nowhere and is **not** buffered (D6). /// @@ -184,17 +184,17 @@ impl RemoteStatus { } // --------------------------------------------------------------------------- -// Reconnect backoff (design §10: 指数退避 1/2/4/…/30s 封顶,无限重试) +// Reconnect backoff (指数退避 1/2/4/…/30s 封顶,无限重试) // --------------------------------------------------------------------------- /// The first wait after a link drops. pub const RECONNECT_FIRST: std::time::Duration = std::time::Duration::from_secs(1); -/// The ceiling the doubling stops at. Design §10 fixes it at 30s: long enough +/// The ceiling the doubling stops at, fixed at 30s: long enough /// that a machine that has been down for an hour is not being probed every /// second, short enough that plugging the cable back in feels immediate. pub const RECONNECT_CAP: std::time::Duration = std::time::Duration::from_secs(30); -/// Design §10's retry schedule: 1, 2, 4, 8, 16, 30, 30, … and **never gives +/// The retry schedule: 1, 2, 4, 8, 16, 30, 30, … and **never gives /// up**. /// /// Giving up is the one thing this must not do. The window stays open in a @@ -239,10 +239,10 @@ impl Backoff { } // --------------------------------------------------------------------------- -// The start-up auth queue (design §10, D7) +// The start-up auth queue (D7) // --------------------------------------------------------------------------- -/// Design §10's "需要认证的窗口一次只弹一个 sheet,其余排队". +/// One auth sheet at a time per window; the rest queue. /// /// **It queues sheets, not connections.** D7 wants machines that need no /// interaction (a key, `ssh-agent`, a connection already authenticated) to @@ -349,7 +349,7 @@ impl Tty7App { /// Whether this window may open a shell on *this* machine. /// - /// The guard on design §3's "never do this". A remote window that spawned a + /// The guard on that "never do this". A remote window that spawned a /// local shell would put two machines in one window — and would do it /// invisibly, because a local shell in a remote window looks exactly like a /// remote one until the first `ls`. @@ -425,10 +425,79 @@ impl Tty7App { } } + /// The short name of the shell a plain new tab on this window's machine + /// lands on — the menu's `default` tag, and the details panel's "shell" row + /// for a pane that never named one. + /// + /// A local window reads the live `Config` global rather than what the probe + /// recorded, so changing `shell` in Settings retags the menu at once. A + /// remote window's default is a fact about the far machine — its own + /// `config.json`, its own `$SHELL` — and only it can report it. + pub(crate) fn default_shell_label(&self, cx: &gpui::App) -> String { + if self.shells_host.is_local() { + crate::core::shells::default_shell_name( + cx.global::() + .shell + .as_ref() + .map(|s| s.program.as_str()), + ) + } else { + self.shells.default_name.clone() + } + } + + /// Refill the "+" dropdown from the machine this window is bound to. + /// + /// The fourth row of the module header's table, in effect: a window that + /// listed *this* computer's shells would hand a remote spawn `/bin/zsh` on a + /// box whose zsh is `/usr/bin/zsh`, and the pane would come up as a spawn + /// failure rather than a shell. So the list is a property of the window's + /// machine, refetched whenever that machine changes or comes back. + /// + /// A machine that isn't reachable yet empties the list rather than keeping + /// the last one: the window is either still connecting (the connect calls + /// back here) or offline, and in both cases a stale menu would be offering + /// picks that cannot be spawned. The dropdown falls back to its plain "New + /// Tab" entry, which the far end resolves with its own default shell. + pub(crate) fn refresh_shells(&mut self, cx: &mut Context) { + let host_id = self.spawn_host(cx); + self.shells_host = host_id; + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, host_id) else { + self.shells = Default::default(); + cx.notify(); + return; + }; + // Off the UI thread: `/etc/shells` plus a `PATH` walk locally, a round + // trip (and a `wsl.exe` spawn on a Windows peer) remotely. + crate::ui::host_ops::HostOps::run( + host, + cx, + |h| h.shells(), + move |app, out, cx| { + // The window may have moved to another machine while this was in + // the air; a late answer for the machine it left is not an answer + // about the one it is on. + if app.shells_host != host_id { + return; + } + app.shells = match out { + Ok(inventory) => inventory, + Err(e) => { + log::warn!("could not list the shells of this window's machine: {e}"); + Default::default() + } + }; + // Nothing else redraws an idle window, and the dropdown's + // closure captures the list at build time. + cx.notify(); + }, + ); + } + /// Every pane in this window, in tab order. /// /// The reconnect walks these: a workspace's panes are exactly the panes of - /// the one window showing it (design §2 — one window, one workspace, one + /// the one window showing it (one window, one workspace, one /// machine), so there is no second place to look. pub(crate) fn panes(&self) -> Vec> { self.tabs @@ -437,7 +506,7 @@ impl Tty7App { .collect() } - /// This window's connection state (design §10's state machine). + /// This window's connection state. /// /// Two sources, in the order that matters. The *picker* flow wins while it /// is running — a window mid-connect is showing that connect, not the @@ -456,7 +525,7 @@ impl Tty7App { RemoteLinks::status_of(cx, self.workspace) } - /// The status strip's button (design §10: [重试] / [抢回]). + /// The status strip's button ([重试] / [抢回]). pub(crate) fn remote_retry(&mut self, cx: &mut Context) { match &self.connect { // Mid-picker: the retry the user can see is the picker's own. @@ -489,12 +558,21 @@ impl Tty7App { }; let target = choice.target.clone(); let label = choice.label.clone(); + // Connecting is the answer to having disconnected, so it clears it — + // otherwise the supervisor would tear this connection back down on its + // next tick, and the user would have pressed Connect to no effect. + cx.default_global::() + .suspended + .remove(&choice.target.host_id()); self.connect = Some(ConnectFlow::Connecting { choice: choice.clone(), }); cx.notify(); - self.watch_for_install_consent(cx); + // A retry after a failed install must not paint the previous attempt's + // bar for the instant before the first new report lands. + remote_connect::clear_install_progress(choice.target.host_id()); + self.watch_for_install_consent(choice.target.host_id(), cx); cx.spawn(async move |this, cx| { let result = cx .background_executor() @@ -513,8 +591,16 @@ impl Tty7App { /// future is still pending, so anything that only looked after it finished /// would deadlock until the consent timeout. The loop ends when the flow /// leaves `Connecting`, so it costs nothing when nothing is connecting. - fn watch_for_install_consent(&self, cx: &mut Context) { + /// + /// It is also what paints the install's progress bar. The bytes arrive far + /// faster than a screen refresh — hundreds of reports over one install — so + /// they land in a slot ([`remote_connect::GuiInstallProgress`]) and this + /// loop samples it, repainting only when the number it reads has actually + /// changed. A connect that installs nothing therefore costs no repaints at + /// all. + fn watch_for_install_consent(&self, host: HostId, cx: &mut Context) { cx.spawn(async move |this, cx| { + let mut painted: Option = None; loop { let connecting = this .update(cx, |this, _| { @@ -529,6 +615,11 @@ impl Tty7App { this.prompt_install_consent(pending, window, cx) }); } + let reported = remote_connect::install_progress_for(host); + if reported != painted { + painted = reported; + let _ = this.update(cx, |_, cx| cx.notify()); + } // The other question a routed connect can raise: a password, a // key passphrase, a host key. Same mailbox shape, same reason it // needs one (the daemon holds the connection, this process holds @@ -553,6 +644,9 @@ impl Tty7App { // drops with the result; nothing to show. return; }; + // Either way the install is over: on success there is nothing left to + // report, and on failure the error needs the space the bar was in. + remote_connect::clear_install_progress(choice.target.host_id()); match result { Ok(connected) => { let home = connected.home.clone(); @@ -575,7 +669,7 @@ impl Tty7App { self.connect = None; } Err(error) => { - // §17: this is a resting state. The window keeps everything it + // This is a resting state. The window keeps everything it // had, says what went wrong, and offers the next move. log::warn!("connect to {} failed: {error}", choice.label); self.connect = Some(ConnectFlow::Failed { choice, error }); @@ -600,7 +694,7 @@ impl Tty7App { /// Make a workspace on the connected machine, rooted at *its* `$HOME`. /// - /// Design §10: "新建的 workspace 落在 `~`" — the remote's, taken from the + /// A new workspace lands in `~` — the remote's, taken from the /// control handshake (`ControlHelloOk.home`), never this client's. A window /// on a Mac opening a workspace on a Linux box must land in /// `/home/`, not `/Users/`. @@ -615,7 +709,7 @@ impl Tty7App { let id = WorkspaceStore::claim_remote(cx, host); // The name is left unset on purpose: `Workspace::display_name` derives // it from the tabs' repo/cwd, which is the same rule a local workspace - // follows and the one design §10 asks for. A workspace that opened in + // follows and the one intended. A workspace that opened in // `~` and then had a repo opened in it renames itself for free. self.push_remote_layout(id, cx); log::info!( @@ -639,7 +733,7 @@ impl Tty7App { ) { self.connect = None; // From here on the machine is the supervisor's business: it is what - // notices the link dropping, retries on design §10's backoff, and puts + // notices the link dropping, retries on the backoff, and puts // the window read-only in between. RemoteLinks::ensure_running(cx); if self.tabs.is_empty() { @@ -654,7 +748,7 @@ impl Tty7App { /// Push this window's workspace record to the machine that owns it. /// - /// The other half of design §10's split: the client keeps `open`, the window + /// The other half of the storage split: the client keeps `open`, the window /// geometry and the pointer; everything that is a fact about the machine /// goes over there. Fire-and-forget on a background task — a failed push is /// a log line, not a modal, because the record is rewritten on every @@ -690,7 +784,7 @@ impl Tty7App { .detach(); } - /// Design §10's "`open: true` remote workspaces reconnect at launch". + /// `open: true` remote workspaces reconnect at launch. /// /// **M6 owns the behaviour**; this owns the seam. Startup opens a window per /// `open: true` workspace regardless of which machine it is on (`main.rs` @@ -725,7 +819,7 @@ impl Tty7App { // ----- prompts ----------------------------------------------------------- - /// Design §12's install consent, on the machine that raised it. + /// Install consent, relayed to the machine that raised it. /// /// A native prompt rather than an in-window sheet, matching every other /// decision in this class in tty7 ("Restart Daemon?", "Quit and Stop @@ -763,7 +857,7 @@ impl Tty7App { .detach(); } - /// Design §12's version skew, for a remote server. + /// Version skew, for a remote server. /// /// Same shape as the local `prompt_daemon_version_mismatch`, and for the /// same reason: the running daemon owns every live pane on that machine, so @@ -795,15 +889,15 @@ impl Tty7App { } } - /// Carry out "Restart Server" (design §12): replace the `tty7-server` on a + /// Carry out "Restart Server": replace the `tty7-server` on a /// machine with this client's build. /// /// **This throws work away and says so.** Every pane the old server hosts /// dies with it — that is what the prompt the user just answered warns /// about, and it is why nothing on the connect path does this on its own. - /// The panes stay on screen in their disconnected state (§17: never - /// auto-close), the supervisor reconnects the machine, and what comes back - /// is a server with no panes in it. + /// The supervisor reconnects the machine, finds a server whose instance id + /// differs from the one it was talking to, and rebuilds each window from its + /// layout: same tabs and splits, new shells, nothing running in them. fn restart_remote_server( &mut self, mismatch: crate::daemon::install::MismatchedRemoteDaemon, @@ -976,7 +1070,7 @@ pub(crate) fn connection_for( } // --------------------------------------------------------------------------- -// The supervisor (design §10's state machine, running) +// The supervisor (the connection state machine, running) // --------------------------------------------------------------------------- /// How often the pump looks at every machine. @@ -1022,13 +1116,32 @@ pub(crate) struct RemoteLinks { /// Workspaces taken over, and by whom. Per **workspace**: one machine can /// hold three of them and lose exactly one. preempted: std::collections::HashMap, + /// Machines the user has deliberately disconnected from. + /// + /// Without this the supervisor would reconnect on the next tick: it keeps a + /// link to every machine with an open workspace, and "the user asked us to + /// stop" is not something it can read off the connection. Cleared by every + /// path that asks to be connected again ([`RemoteLinks::retry_now`], the + /// switcher's connect), and by [`pump_tick`] the moment a machine's last + /// window closes — from there a reopened workspace connects like any other. + suspended: std::collections::HashSet, + /// The `tty7-server` **process** each machine was last served by + /// ([`crate::daemon::control::server_instance`]). + /// + /// Kept per machine and consulted on every reconnect, because it is the only + /// thing that distinguishes the two ways a link comes back: to the same + /// process (every `pane_id` still names the pane it always did) or to a new + /// one (they name nothing, and the window has to be rebuilt). A machine + /// absent from this map has never been seen before, which is **not** the + /// same as having restarted — see [`finish_attempt`]. + instances: std::collections::HashMap, /// Workspaces this client is pushing a layout for right now. /// /// Read by [`refresh_remote_workspace`], which skips them: a record we are /// in the middle of replacing is not one to pull back over the top of /// ourselves. pushing: std::collections::HashSet, - /// Design §10's start-up sheet queue. Lives here because it is part of the + /// The start-up sheet queue. Lives here because it is part of the /// same connection state and has to survive individual windows — the sheet /// belongs to a machine, not to whichever window happened to ask first. #[allow( @@ -1111,7 +1224,7 @@ impl RemoteLinks { /// The user asked to reconnect, or to take a preempted workspace back. /// - /// Both are the same operation. Design §10's [抢回] is "接管一次" in the + /// Both are the same operation. The [抢回] is "接管一次" in the /// other direction, and a takeover *is* an attach — so clearing the /// preemption and letting the supervisor attach is not a shortcut, it is the /// mechanism. @@ -1121,6 +1234,8 @@ impl RemoteLinks { }; let links = cx.default_global::(); links.preempted.remove(&workspace); + // Asking to reconnect outranks having asked to disconnect. + links.suspended.remove(&host.host_id()); let link = links.machines.entry(host.host_id()).or_insert(MachineLink { state: LinkState::Reconnecting, backoff: Backoff::default(), @@ -1139,6 +1254,35 @@ impl RemoteLinks { cx.refresh_windows(); } + /// Stop holding a connection to `host`, because the user said so. + /// + /// **Nothing closes.** The windows on that machine stay exactly where they + /// are and go read-only, which is the same resting state a dropped link + /// leaves behind (a window is never closed automatically) — with the difference + /// that this one is one click from being undone: no `MachineLink` at all + /// reads as [`RemoteStatus::Disconnected`], and that state already draws a + /// "Connect" button on the window's strip. + /// + /// Closing the user's windows for them would be a different, destructive + /// act wearing the same word. If that is what they want, closing a window is + /// already how it is said — and it disconnects too, by way of the machine + /// going unbound. + pub(crate) fn disconnect(cx: &mut gpui::App, host: HostId) { + cx.default_global::().suspended.insert(host); + // Let go of the streams before the connection: a pane still holding one + // would keep reading from a socket that is about to be dropped under it. + for (workspace, _) in workspaces_on(cx, host) { + release_panes(cx, workspace); + cx.default_global::() + .preempted + .remove(&workspace); + } + remote_connect::RemoteConnections::remove(cx, host); + cx.default_global::().machines.remove(&host); + log::info!("disconnected from a machine at the user's request"); + cx.refresh_windows(); + } + fn mark(cx: &mut gpui::App, host: HostId, f: impl FnOnce(&mut MachineLink)) { let link = cx .default_global::() @@ -1157,7 +1301,7 @@ impl RemoteLinks { /// One turn of the supervisor. `false` ends the pump. fn pump_tick(cx: &mut gpui::App) -> bool { drain_events(cx); - // Design §10, D7: start-up and reconnect are the two moments a dozen + // D7: start-up and reconnect are the two moments a dozen // machines can ask for a password at once, and both go through here. pump_auth_sheets(cx); @@ -1170,14 +1314,30 @@ fn pump_tick(cx: &mut gpui::App) -> bool { // exists, and a stale one would have a reopened workspace come back // read-only against a takeover that happened to a window that is gone. let links = cx.default_global::(); + let forgotten = links.machines.len(); links.machines.clear(); links.preempted.clear(); + links.suspended.clear(); + // Logged because the *state* it leaves behind is indistinguishable from + // never having connected: `status_of` reads a missing link as + // `Disconnected`, so a window whose workspace is somehow not `open` + // sits under a "Not connected" strip with a live machine behind it. + // This line is how that is told apart from a real disconnect. + log::info!("supervisor stopped: no open remote workspace ({forgotten} link(s) dropped)"); return false; } + prune_suspended(&mut cx.default_global::().suspended, &bound); + let suspended = cx.default_global::().suspended.clone(); + let now = Instant::now(); let mut changed = false; for (host, target) in bound { + // Skipped before the liveness check, not after: the point is that this + // machine gets no attempt at all, not that it gets a quieter one. + if suspended.contains(&host) { + continue; + } let live = remote_connect::RemoteConnections::get(cx, host) .is_some_and(|h| h.client().is_connected()); let attempting = cx @@ -1186,12 +1346,17 @@ fn pump_tick(cx: &mut gpui::App) -> bool { .is_some_and(|l| l.attempting); if live { + let mut became = false; RemoteLinks::mark(cx, host, |link| { - changed |= link.state != LinkState::Attached; + became = link.state != LinkState::Attached; link.state = LinkState::Attached; link.backoff.reset(); link.next_attempt = None; }); + if became { + changed = true; + log::info!("link to {target} is attached"); + } continue; } if attempting { @@ -1199,7 +1364,7 @@ fn pump_tick(cx: &mut gpui::App) -> bool { } // The link is down. Drop the dead host object so nothing keeps calling - // into it — §17: a control connection that has gone is the whole + // into it — a control connection that has gone is the whole // workspace's lifeline, not one failed request. if remote_connect::RemoteConnections::get(cx, host).is_some() { remote_connect::RemoteConnections::remove(cx, host); @@ -1236,6 +1401,23 @@ fn pump_tick(cx: &mut gpui::App) -> bool { true } +/// A deliberate disconnect lasts exactly as long as there is a window to be +/// disconnected *in*. +/// +/// Once a machine's last workspace closes, the state has nothing left to +/// describe — and remembering it would leave a workspace reopened an hour later +/// sitting offline for no reason the user could see, with no failure to point +/// at. Closing the window is itself an end to the connection; this is that, +/// written down. +/// +/// Pure so the rule is a test rather than a comment. +fn prune_suspended( + suspended: &mut std::collections::HashSet, + bound: &[(HostId, RemoteTarget)], +) { + suspended.retain(|host| bound.iter().any(|(id, _)| id == host)); +} + /// Every machine this client should be holding a connection to: the distinct /// hosts of the remote workspaces whose windows are open. /// @@ -1284,7 +1466,7 @@ fn drain_events(cx: &mut gpui::App) { let stale = stale_workspaces(&events); for (host, event) in events { match event { - // Design §10's takeover, arriving. The window goes read-only and + // The takeover, arriving. The window goes read-only and // **stays** — no automatic reconnect, because reconnecting is // taking it back, and taking it back is the user's decision. ControlEvent::Preempted { workspace, by } => { @@ -1343,7 +1525,7 @@ fn stale_workspaces(events: &[(HostId, ControlEvent)]) -> Vec<(HostId, String)> /// So there is no path from here to a closed tab, a re-spawned pane or a moved /// focus; what a user sees change is the workspace's *name*. /// -/// That is deliberate rather than incidental. Design §10 makes the remote the +/// That is deliberate rather than incidental. The remote is the /// authority for the layout, but the client that has the window open is the one /// *living* in it, and rearranging somebody's panes underneath them because /// another machine moved a tab is not a refresh, it is a fight. The remote's @@ -1386,7 +1568,7 @@ fn refresh_remote_workspace(cx: &mut gpui::App, host: HostId, store_key: String) }); } // The workspace was deleted on the far side. The window stays open - // with what it had — §10 never closes a window, and least of all + // with what it had — a window is never closed, and least of all // because another machine decided this one was done with it. Err(e) if e.kind() == std::io::ErrorKind::NotFound => { log::info!("remote workspace {id} is gone from its machine; keeping the window"); @@ -1407,9 +1589,14 @@ fn refresh_remote_workspace(cx: &mut gpui::App, host: HostId, store_key: String) /// `retry_now` (rather than letting the backoff schedule it) because a restart /// the user asked for should come back at once — this is exactly the "the user /// pressed the button is information the backoff does not have" case it exists -/// for. **The panes do not come back**: their ids named panes in the old daemon, -/// so each relink fails and each pane stays on screen, disconnected. That is -/// what "Restart Server ends every session it is hosting" meant. +/// for. +/// +/// **The sessions do not come back, but the window does.** Their pane ids named +/// panes in the old process, so nothing re-attaches; the reconnect notices the +/// new [`server_instance`](crate::daemon::control::server_instance) and rebuilds +/// each window from its layout — fresh shells in their saved cwds, agents +/// resumed where an id was captured. That is what "Restart Server ends every +/// session it is hosting" means: the work in them is gone, the window is not. fn reconnect_after_restart(origin: &str, cx: &mut gpui::App) { let Some(host) = remote_connect::origin_host(origin) else { return; @@ -1432,7 +1619,7 @@ fn client_id_for(cx: &gpui::App, host: HostId, store_key: &str) -> Option { // A profile that has been deleted is not a network failure, and // retrying it for ever would be. This is a resting state with a - // button, per §17. + // button. RemoteLinks::mark(cx, host, |link| { link.state = LinkState::Failed(e); link.next_attempt = None; @@ -1467,7 +1654,7 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { .background_executor() .spawn(async move { let connected = remote_connect::connect_blocking(&target, header, &label_for_task)?; - // Design §10: the attach is what makes this client the + // The attach is what makes this client the // workspace's session again — and what preempts whoever took it // while we were away. for key in &keys { @@ -1505,9 +1692,11 @@ fn finish_attempt( ) { match outcome { Ok(connected) => { - // The remote's record is the authority for the layout (design §10), + // The remote's record is the authority for the layout, // so what came back with the connect replaces what this client had. let rows = connected.rows.clone(); + let instance = connected.host.peer().instance.clone(); + let restarted = server_restarted(cx, host, &connected.host); // The home too, not just the connection: this is the path a machine // comes back on after a restart or a dropped link, and dropping it // here is what left "New Workspace" missing on a machine the panel @@ -1518,11 +1707,33 @@ fn finish_attempt( WorkspaceStore::apply_remote(cx, id, &row.record); } cx.default_global::().preempted.remove(&id); - relink_panes(cx, id); - // A window that came up before its machine did has no panes to - // relink — it opened empty because there was nothing to route - // to. Now there is. - hydrate_window(cx, id); + // The same question `restarted` answers, asked of the *record* + // rather than of this process's memory — and it is the only one + // that can answer across a client restart. `instances` is an + // in-memory map, so on a cold launch every machine is a first + // sighting and `restarted` is false; a server replaced while + // this client was closed would sail through, and its recycled + // ids would attach to whatever unrelated shells now hold the + // numbers. `daemon_instance` is on disk and remembers. + let stale = WorkspaceStore::forget_stale_pane_ids(cx, id, &instance); + if restarted || stale { + // Every pane id this workspace holds was minted by a process + // that is gone. Re-attaching them would cost one doomed round + // trip each and leave the window exactly as disconnected as + // it is now, so the window is rebuilt from the layout instead + // — the same thing a local daemon restart does. + rebuild_after_server_restart(cx, id); + } else { + relink_panes(cx, id); + // A window that came up before its machine did has no panes to + // relink — it opened empty because there was nothing to route + // to. Now there is. + hydrate_window(cx, id); + } + // Same reason the window had no panes: with the machine + // unreachable there was nothing to ask for its shells, so the + // "+" dropdown has been sitting on the empty fallback. + refresh_window_shells(cx, id); } RemoteLinks::mark(cx, host, |link| { link.state = LinkState::Attached; @@ -1546,7 +1757,7 @@ fn finish_attempt( cx.refresh_windows(); } -/// Design §10's pane half of a reconnect: for each pane, reopen a channel, +/// The pane half of a reconnect: for each pane, reopen a channel, /// `Attach`, take the replay, then `Resize` to this client's geometry. /// /// # The replay boundary @@ -1603,7 +1814,7 @@ fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) { } }); } - // §17: a pane that could not come back stays on screen in its + // A pane that could not come back stays on screen in its // disconnected state. The supervisor is still retrying the // machine, and the next success runs this again. Err(e) => log::warn!("could not relink pane {pane_id}: {e}"), @@ -1662,11 +1873,120 @@ fn hydrate_window(cx: &mut gpui::App, workspace: WorkspaceId) { }); } -/// Design §10's takeover, on this client's side: stop holding a stream to a +/// Whether the machine we just reconnected to is being served by a *different* +/// `tty7-server` process than the one we last spoke to. +/// +/// `true` is a statement of fact, not a guess, and that is the whole point: it +/// is the difference between a link that blinked (re-attach; the shells are +/// still running over there) and a server that was replaced (rebuild; they are +/// not). Before the instance id existed there was nothing to tell them apart — +/// `build` and both dialect numbers survive a restart unchanged — so the +/// reconnect had to assume the safer of the two and leave dead panes on screen. +/// +/// Two cases answer `false` and mean different things, both deliberately: +/// +/// | | | +/// |---|---| +/// | No previous instance recorded | First time we have reached this machine. Nothing was attached, so nothing was lost | +/// | The peer reported no instance | We cannot tell. Never treat "don't know" as "restarted" — that would throw away live shells on a hunch | +fn server_restarted(cx: &mut gpui::App, host: HostId, peer: &RemoteHost) -> bool { + let instance = peer.peer().instance.clone(); + let seen = &mut cx.default_global::().instances; + note_instance(seen, host, &instance) +} + +/// Record `instance` as what is serving `host` and answer whether it displaced a +/// *different* one. Split out from [`server_restarted`] because the rule matters +/// more than the plumbing around it and is worth a test that doesn't need a +/// connection to run. +fn note_instance( + seen: &mut std::collections::HashMap, + host: HostId, + instance: &str, +) -> bool { + if instance.is_empty() { + // Nothing learned, so nothing recorded: writing an empty value would + // make the *next* reconnect compare against it and read a real instance + // as a restart. + return false; + } + match seen.insert(host, instance.to_string()) { + Some(before) if before != instance => { + log::info!( + "the tty7-server on this machine is a new process ({before} → {instance}); \ + its panes are gone" + ); + true + } + _ => false, + } +} + +/// Rebuild a workspace's window after its machine's server was replaced. +/// +/// The local analogue is [`Tty7App::restart_daemon_confirmed`], and this is +/// deliberately the same shape: the layout is the thing that survives, and every +/// leaf in it comes back as a fresh shell in its saved cwd. What makes it safe +/// here is only that [`server_restarted`] *knew* — the same rebuild triggered by +/// a guess would be a way to lose running work. +/// +/// **The saved pane ids are dropped first**, and that is what makes the resume +/// work rather than being a tidiness measure. `session_to_pane` keeps a remote +/// leaf's id unconditionally (its liveness cannot be probed without a round +/// trip) and lets the attach fail into a spawn *inside* the terminal — by which +/// point the code that would have sent `claude --resume ` has already +/// decided it wasn't needed. Clearing the ids up here makes the leaf take the +/// same path a dead local pane takes, so the agent conversation continues. +/// +/// Unlike [`hydrate_window`] this does **not** skip a window with tabs. Those +/// tabs are precisely what has to go: every one of them is a pane bound to a +/// process that no longer exists. +fn rebuild_after_server_restart(cx: &mut gpui::App, workspace: WorkspaceId) { + let mut session = match WorkspaceStore::all(cx).get(workspace) { + Some(entry) if entry.is_remote() => entry.session.clone(), + _ => return, + }; + if session.tabs.is_empty() { + return; + } + for tab in &mut session.tabs { + tty7_core::core::session::blank_pane_ids(&mut tab.pane); + } + let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else { + return; + }; + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) + else { + return; + }; + log::info!( + "rebuilding {} tab(s) of workspace {workspace}: its machine is serving a new process", + session.tabs.len() + ); + let _ = handle.update(cx, move |_, window, cx| { + app.update(cx, |app, cx| { + app.adopt_workspace(workspace, session, window, cx) + }); + }); +} + +/// Ask the window showing `workspace` to refill its "+" dropdown, now that its +/// machine is answering. No-op for a workspace with no window on screen. +fn refresh_window_shells(cx: &mut gpui::App, workspace: WorkspaceId) { + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) + else { + return; + }; + app.update(cx, |app, cx| app.refresh_shells(cx)); +} + +/// The takeover, on this client's side: stop holding a stream to a /// workspace somebody else is now typing in. /// /// The remote closes them too — this is not the mechanism, it is the client -/// making sure. The panes stay on screen (read-only, per §10's "永不自动关窗"); +/// making sure. The panes stay on screen (read-only, never auto-closed); /// only the links go. fn release_panes(cx: &mut gpui::App, workspace: WorkspaceId) { for view in panes_of(cx, workspace) { @@ -1675,13 +1995,13 @@ fn release_panes(cx: &mut gpui::App, workspace: WorkspaceId) { } // --------------------------------------------------------------------------- -// The start-up auth queue, wired (design §10, D7) +// The start-up auth queue, wired (D7) // --------------------------------------------------------------------------- /// Move every routed auth prompt one step: drain the mailbox, offer each to the /// queue, and raise the sheet for whichever machine's turn it is. /// -/// Design §10's rule is "**一次只弹一个 sheet,其余排队**", and the queue is keyed +/// The rule is "**一次只弹一个 sheet,其余排队**", and the queue is keyed /// by machine because the credential is the machine's — ten windows restoring /// onto one box must ask for one password, not ten. /// @@ -1804,7 +2124,7 @@ fn panes_of( /// Whether keystrokes should reach the panes of `workspace`. /// -/// **The one call the pane layer makes.** Design §10's read-only degrade has to +/// **The one call the pane layer makes.** The read-only degrade has to /// be checked at every point a keystroke can enter a pane — `on_key_down`, the /// IME's `commit_text`, `paste`, `send_to_pty`, and the typeahead `dump_hold` /// timer — and a rule copied five times is a rule that will disagree with itself @@ -1856,7 +2176,7 @@ mod tests { ); } - /// Design §10's read-only degrade: a window that is connecting or failed + /// The read-only degrade: a window that is connecting or failed /// still shows and scrolls, but typing goes nowhere — and is not buffered /// for later (D6), which is why this is a gate and not a queue. #[test] @@ -1891,9 +2211,107 @@ mod tests { assert_eq!(flow.choice(), Some(&choice)); } - // ── The reconnect schedule (design §10; contract §18: no network) ──────── + /// The rule that decides re-attach vs rebuild. Getting any of these four + /// wrong costs the user running work: a false positive rebuilds a window + /// whose shells were fine, a false negative leaves a screen of dead panes. + #[test] + fn only_a_changed_instance_counts_as_a_restart() { + let mut seen = std::collections::HashMap::new(); + let host = HostId::from_connection_key("ssh:build-box"); - /// Design §10 fixes the schedule: **1/2/4/…/30s capped, retried for ever**. + // First sight of a machine. Nothing was attached to the old process + // because there was no old process *we* knew about. + assert!(!note_instance(&mut seen, host, "abc")); + // The link blinked and came back to the same server. + assert!(!note_instance(&mut seen, host, "abc")); + // Replaced. + assert!(note_instance(&mut seen, host, "def")); + // …and the new one is now the baseline, so it is not a restart twice. + assert!(!note_instance(&mut seen, host, "def")); + } + + /// A peer that reports no instance leaves no trace. Recording the empty + /// string would make the *next* reconnect — one that does report an id — + /// read as a restart and throw away live shells. + #[test] + fn an_unknown_instance_is_not_a_restart_and_is_not_remembered() { + let mut seen = std::collections::HashMap::new(); + let host = HostId::from_connection_key("ssh:build-box"); + + assert!(!note_instance(&mut seen, host, "")); + assert!(seen.is_empty(), "an unknown instance must not be recorded"); + assert!( + !note_instance(&mut seen, host, "abc"), + "the first real instance is a first sighting, not a restart" + ); + } + + /// Two machines are tracked apart. They mint instance ids independently, so + /// one restarting must not rebuild the other's windows. + #[test] + fn instances_are_per_machine() { + let mut seen = std::collections::HashMap::new(); + let a = HostId::from_connection_key("ssh:box-a"); + let b = HostId::from_connection_key("ssh:box-b"); + + assert!(!note_instance(&mut seen, a, "a1")); + assert!(!note_instance(&mut seen, b, "b1")); + assert!(note_instance(&mut seen, a, "a2")); + assert!( + !note_instance(&mut seen, b, "b1"), + "box-b never changed; box-a restarting is not its business" + ); + } + + /// Every leaf loses its id, at every depth. A `Split` branch that kept its + /// ids would leave those panes attaching to a dead process — and, worse, + /// skipping the agent resume, because that only fires for a leaf with no id. + #[test] + fn forgetting_pane_ids_reaches_every_leaf() { + use crate::core::session::{SessionAxis, SessionPane}; + + fn leaf(id: u64) -> SessionPane { + SessionPane::Leaf { + cwd: None, + pane_id: Some(id), + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + } + } + fn ids(pane: &SessionPane, out: &mut Vec>) { + match pane { + SessionPane::Leaf { pane_id, .. } => out.push(*pane_id), + SessionPane::Split { a, b, .. } => { + ids(a, out); + ids(b, out); + } + } + } + + let mut pane = SessionPane::Split { + axis: SessionAxis::Horizontal, + ratio: 0.5, + a: Box::new(leaf(1)), + b: Box::new(SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.5, + a: Box::new(leaf(2)), + b: Box::new(leaf(3)), + }), + }; + let forgotten = tty7_core::core::session::blank_pane_ids(&mut pane); + + let mut found = Vec::new(); + ids(&pane, &mut found); + assert_eq!(found, vec![None, None, None]); + assert_eq!(forgotten, 3, "every dropped claim is counted"); + } + + // ── The reconnect schedule (no network) ───────────────────────────────── + + /// The schedule is fixed: **1/2/4/…/30s capped, retried for ever**. #[test] fn the_backoff_doubles_to_thirty_seconds_and_stays_there() { let mut b = Backoff::default(); @@ -1929,7 +2347,7 @@ mod tests { assert_eq!(b.delay(), RECONNECT_CAP, "still retrying, still capped"); } - // ── The start-up auth queue (design §10, D7) ───────────────────────────── + // ── The start-up auth queue (D7) ───────────────────────────── fn host(key: &str) -> HostId { HostId::from_connection_key(key) @@ -2079,9 +2497,9 @@ mod tests { assert!(stale_workspaces(&events).is_empty()); } - // ── The read-only degrade, state by state (design §10) ─────────────────── + // ── The read-only degrade, state by state ─────────────────── - /// Design §10's degrade in one table: which states are read-only, what the + /// The degrade in one table: which states are read-only, what the /// bottom line says, and what the strip offers to do about it. /// /// `Preempted` reads differently on purpose — "not connected" would be a @@ -2132,7 +2550,7 @@ mod tests { } /// The two states M6 added still produce a strip line, and the takeover one - /// names the machine that took it — design §10: 状态条写"已在 <主机名> 上打开". + /// names the machine that took it — 状态条写"已在 <主机名> 上打开". #[test] fn the_new_states_name_what_happened() { assert_eq!( @@ -2157,4 +2575,84 @@ mod tests { Some("This workspace was opened on desktop") ); } + + // ── A deliberate disconnect ────────────────────────────────────────────── + + fn machine(alias: &str) -> (HostId, RemoteTarget) { + let target = RemoteTarget::Alias { + alias: alias.to_string(), + }; + (target.host_id(), target) + } + + /// A disconnect holds only while the machine still has a window on it. + /// + /// The supervisor reconnects to every machine with an open workspace, so + /// without this set a disconnect would last one tick. With it kept + /// *forever*, the opposite failure: a workspace closed and reopened a day + /// later would come up offline against a decision the user has no memory of + /// and nothing on screen to explain. + #[test] + fn a_disconnect_ends_when_the_last_window_on_that_machine_closes() { + let (build, build_t) = machine("build-box"); + let (gpu, gpu_t) = machine("gpu-lab"); + let mut suspended = std::collections::HashSet::from([build, gpu]); + + // Both still have a window: both decisions still mean something. + prune_suspended(&mut suspended, &[(build, build_t.clone()), (gpu, gpu_t)]); + assert_eq!(suspended.len(), 2); + + // The gpu box's last workspace closed. Its disconnect goes with it; the + // build box's is untouched. + prune_suspended(&mut suspended, &[(build, build_t)]); + assert_eq!( + suspended.into_iter().collect::>(), + vec![build], + "closing one machine's window must not resume another" + ); + } + + /// Disconnecting drops the machine's link state, which *is* how the window + /// says "not connected": `status_of` reads a missing `MachineLink` as + /// `Disconnected`, and that state already draws the Connect button. Asking + /// to reconnect then clears the decision, or the supervisor would undo the + /// reconnect on its next tick. + #[gpui::test] + fn disconnecting_rests_at_not_connected_and_connect_undoes_it(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + crate::core::config::pin_test_config_dir(); + cx.set_global(crate::core::config::Config::default()); + crate::ui::windows::WindowRegistry::init(cx); + + let (host, target) = machine("build-box"); + let mut entry = crate::core::session::Workspace::on_remote(RemoteRef::new( + target, + WorkspaceId::new(), + )); + entry.open = true; + let id = entry.id; + WorkspaceStore::install_for_test( + cx, + crate::core::session::Workspaces { + workspaces: vec![entry], + active: None, + }, + ); + + RemoteLinks::disconnect(cx, host); + assert!(cx.default_global::().suspended.contains(&host)); + assert_eq!( + RemoteLinks::status_of(cx, id), + Some(RemoteStatus::Disconnected), + "a disconnected machine rests where a never-connected one does" + ); + + // The strip's Connect button. + RemoteLinks::retry_now(cx, id); + assert!( + !cx.default_global::().suspended.contains(&host), + "asking to connect must outrank having asked to disconnect" + ); + }); + } } diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 9df59afe..dcaabf96 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -90,7 +90,7 @@ pub(crate) struct RightPanelState { /// runs, or `None` when the pane on screen has nothing to forward over. /// /// A route rather than a `bool` because a remote workspace's forwards belong - /// to the *workspace*, not the pane (design §15): the pane id alone cannot + /// to the *workspace*, not the pane: the pane id alone cannot /// say which of the two owners to ask, and the reschedule below re-reads /// this rather than carrying the decision forward. pub(crate) procs_forwards: Option, @@ -594,17 +594,20 @@ impl Tty7App { rows.push(("cwd", compact_path(&cwd))); cwd_for_actions = Some(cwd); } - let shell = view.shell_spec().map(|s| s.program.clone()); - rows.push(( - "shell", - crate::core::shells::default_shell_name(shell.as_deref()), - )); + // A pane that named no shell took its machine's default — which + // for a remote workspace is the *far* machine's, not this + // computer's `$SHELL`. + let shell = match view.shell_spec().map(|s| s.program.clone()) { + Some(program) => crate::core::shells::default_shell_name(Some(&program)), + None => self.default_shell_label(cx), + }; + rows.push(("shell", shell)); if let Some(ssh) = view.ssh_spec() { rows.push(("ssh", ssh.host.clone())); } // Two ways a pane has something to forward over: it *is* an // SSH session, or it belongs to a remote workspace, whose - // forwards run on the workspace's own connection (design §15). + // forwards run on the workspace's own connection. // The second arm is empty in this build — nothing binds a pane // to a workspace yet — which is deliberate: the band stays // empty rather than offering an add that would have nowhere to diff --git a/src/ui/settings.rs b/src/ui/settings.rs index cd494207..466c2233 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -572,7 +572,7 @@ pub(crate) enum AgentHooksView { /// [`crate::core::agent_hooks::HookAgent::ALL`] order. Ready(Vec), /// The machine can't be acted on, and the sentence says which hop gave up - /// (design §17: a failure is a resting state, not a blank). + /// (a failure is a resting state, not a blank). Unavailable(String), } @@ -4279,7 +4279,7 @@ impl Tty7App { ) .into_any_element(); } - // §17: a resting state that says which hop gave up and what to do + // A resting state that says which hop gave up and what to do // next, rather than rows that would silently write nowhere. AgentHooksView::Unavailable(reason) => { return page diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 85b6ee02..c4ad67fc 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -73,7 +73,7 @@ pub(crate) enum SftpEdit { } /// Which SSH connection this panel's requests run on — the *only* thing that -/// differs between an SSH pane and a remote workspace (design §15). +/// differs between an SSH pane and a remote workspace. /// /// A plain `Copy`-able bundle rather than a lookup at each call site, because /// every request runs on a background executor: the pane entity is not reachable @@ -173,8 +173,8 @@ pub(crate) struct SftpPanelState { /// not by a toggle: the browser is a *view of the pane*, so which pane you're /// looking at is the only thing that decides it. pub(crate) open_pane_id: Option, - /// The remote workspace the open pane belongs to, when it is one (design - /// §15). Captured beside `open_pane_id` because every SFTP call needs it and + /// The remote workspace the open pane belongs to, when it is one. + /// Captured beside `open_pane_id` because every SFTP call needs it and /// the calls run on a background executor, where the pane entity is out of /// reach. `None` — the case for SSH panes — keeps the pane-addressed path. pub(crate) open_workspace: Option, @@ -417,7 +417,7 @@ impl Tty7App { } /// How this panel's requests reach the far side: the open pane's own - /// connection, or — for a remote-workspace pane — the workspace's (§15). + /// connection, or — for a remote-workspace pane — the workspace's. /// /// Resolved on the UI thread and cloned into every background call, because /// the pane entity is not reachable from a background executor. diff --git a/src/ui/ssh_prompt.rs b/src/ui/ssh_prompt.rs index da97c89e..21c12fd8 100644 --- a/src/ui/ssh_prompt.rs +++ b/src/ui/ssh_prompt.rs @@ -598,7 +598,7 @@ impl Tty7App { fn dismiss_and_advance(&mut self, window: &mut Window, cx: &mut Context) { let pane = self.ssh_prompt.pane.clone(); - // Design §10 / D7: the sheet is one machine's turn. Handing it back is + // D7: the sheet is one machine's turn. Handing it back is // what lets the next machine's queued connect ask its question, so it // has to happen on every exit from a routed sheet — answered, cancelled // or dismissed. diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index b552ebc0..379d9f4d 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -48,9 +48,10 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f use tty7_core::core::session::{RemoteTarget, WorkspaceId}; use crate::core::session::WorkspaceStore; +use crate::daemon::install::InstallPhase; use crate::terminal::pane_liveness::Liveness; use crate::ui::app::Tty7App; -use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow}; +use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow, human_bytes}; use crate::ui::remote_workspace::ConnectFlow; /// Card width — the command palette's, to the pixel. @@ -115,6 +116,11 @@ const ROW_PAD: f32 = 8.0; /// is what 52px bought.) const WHEN_W: f32 = 96.0; +/// Height of the install bar's track. Thin on purpose: it is a thing to glance +/// at while waiting, not a control, and anything taller starts to compete with +/// the workspace rows under it for the eye. +const PROGRESS_H: f32 = 3.0; + /// What a machine's connection is doing, as far as this panel is concerned. /// /// Deliberately coarser than @@ -131,7 +137,7 @@ enum Link { /// A connect is in flight right now. Connecting, /// The last attempt failed. A resting state, not a transient one: design - /// §17 says a failure always stays put and offers the next move, so the + /// A failure always stays put and offers the next move, so the /// reason rides along on the group (see [`Group::error`]). Failed, /// No connection. **Not an error** — the rows below it are what this client @@ -162,6 +168,12 @@ struct Group { /// Why the last connect failed, shown under the header until the user acts /// on it. error: Option, + /// How far this machine's first install has got, while one is running. + /// + /// Shares the header's under-slot with [`error`](Self::error) and cannot + /// collide with it: a connect is either still installing or has already + /// failed. + installing: Option, rows: Vec, } @@ -322,6 +334,7 @@ impl Tty7App { link: Link::Offline, home: None, error: None, + installing: None, rows: Vec::new(), }); groups.len() - 1 @@ -351,7 +364,7 @@ impl Tty7App { // looks like nothing happened: the connect runs, succeeds, and has // nowhere to land — groups came only from workspaces this client had // records for, and a machine that has never been used has none. That is - // also precisely the machine whose "New Workspace" row the user needs. + // also precisely the machine the user is about to make a workspace on. for target in self.pending_machines() { let key = target.to_string(); if index.contains_key(&key) { @@ -366,6 +379,7 @@ impl Tty7App { link: Link::Offline, home: None, error: None, + installing: None, rows: Vec::new(), }); } @@ -385,6 +399,7 @@ impl Tty7App { link: Link::Offline, home: None, error: None, + installing: None, rows: Vec::new(), }, ); @@ -425,6 +440,12 @@ impl Tty7App { group.error = Some(error.clone()); } let id = target.host_id(); + // Only while *this* window is the one connecting. Another window's + // install is its own business, and a bar under a row this panel is + // not driving would have no "Try Again" to turn into. + if group.link == Link::Connecting { + group.installing = remote_connect::install_progress_for(id); + } // Read app-wide, not from this window's snapshot: any window's // connect, and every reconnect, records the machine's `$HOME` — and // that row is the only way to make a workspace on a machine, so it @@ -578,6 +599,29 @@ impl Tty7App { cx.notify(); } + /// Stop holding a connection to a machine. + /// + /// The panel stays open and the machine keeps its group: its workspaces are + /// still there, still listed from what this client remembers, and the header + /// now reads "not connected" — clicking it connects again. Windows showing + /// them stay open and go read-only; see [`RemoteLinks::disconnect`] for why + /// this closes nothing. + fn switcher_disconnect(&mut self, target: &RemoteTarget, cx: &mut Context) { + crate::ui::remote_workspace::RemoteLinks::disconnect(cx, target.host_id()); + // A finished connect flow for this machine described an attempt that has + // just been undone; leaving it would keep painting a stale error (or a + // success) over the group that no longer holds. + if self + .connect + .as_ref() + .and_then(ConnectFlow::choice) + .is_some_and(|c| &c.target == target) + { + self.connect = None; + } + cx.notify(); + } + /// Make a workspace on a machine. Local goes through the ordinary /// `NewWorkspace` path; a remote one lands in *that machine's* `$HOME`. fn switcher_new(&mut self, group: &GroupRef, window: &mut Window, cx: &mut Context) { @@ -717,7 +761,7 @@ impl Tty7App { /// The one row the panel keeps below the fold — adding a machine it does not /// know about yet — and the one gesture nothing else advertises. /// - /// Deliberately *not* an "add host" form. Design §2 is that a machine is + /// Deliberately *not* an "add host" form. A machine is /// configured once and remote workspaces reuse whatever is already set up, /// so this points at where that lives instead of growing a second place to /// do it. @@ -819,7 +863,10 @@ impl Tty7App { let mut block = v_flex().gap(px(1.)); block = block.child(self.render_group_header(group, expanded, cx)); - // §17: a failure is a resting state — it stays on screen with its reason + if let Some(phase) = group.installing { + block = block.child(self.render_install_progress(phase, cx)); + } + // A failure is a resting state — it stays on screen with its reason // in full and its next move one click away, rather than reverting the // panel and leaving the user to guess between VPN, keys and the box. if let Some(error) = group.error.as_ref() { @@ -867,22 +914,89 @@ impl Tty7App { ), ); } - if expanded { + // A machine with no workspaces on it renders as its header alone. There + // used to be a "New Workspace" row to fill the space; it lives in the + // header's `⋯` now, so an empty group has nothing under it and must not + // draw an indent block (and a guide rail) around nothing. + if expanded && !rows.is_empty() { let mut kids = v_flex().gap(px(1.)); for row in rows { kids = kids.child(self.render_row(group, row, cx)); } - // "New Workspace" needs a directory it is not making up, and only a - // handshake can supply the remote's. A connected machine always has - // one; this computer needs none. - if query.is_empty() && (group.target.is_none() || group.home.is_some()) { - kids = kids.child(self.render_new_row(group, cx)); - } block = block.child(self.indent(group, kids, cx)); } Some(block.into_any_element()) } + /// The bar under a machine that is being installed onto for the first time. + /// + /// Sits in the same slot as the failure box, indented and inset to the same + /// numbers, because it is the same kind of thing: a sentence about this + /// machine's connect that outlives a single frame. The two can never both be + /// present — a connect is either still running or has already failed — so + /// the slot needs no arbitration. + /// + /// No border, unlike the failure box. A failure is a thing to act on and + /// earns an outline; this is a thing to wait through, and a box around it + /// would give a routine 20 seconds the weight of an error. + fn render_install_progress( + &self, + phase: InstallPhase, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let theme = cx.theme(); + // The same warning colour the header's dot and "installing…" already + // use, so the row and the bar read as one state and not two. + let accent = theme.warning; + let (verb, done, total) = match phase { + InstallPhase::Downloading { done, total } => ("Downloading", done, total), + InstallPhase::Uploading { done, total } => ("Copying", done, Some(total)), + }; + // An unknown total (no Content-Length) still gets a line of text and a + // bar — just an empty one. A bar that guessed at a fraction would be + // lying, and one that vanished would read as the install having stopped. + let fraction = phase.fraction().unwrap_or(0.0); + let caption = match total { + Some(total) => format!( + "{verb} tty7's server… {} / {}", + human_bytes(done), + human_bytes(total) + ), + None => format!("{verb} tty7's server… {}", human_bytes(done)), + }; + + v_flex() + .gap(px(6.)) + .ml(px(KID_INDENT)) + .mr(px(4.)) + .mb(px(2.)) + .px(px(10.)) + .py(px(8.)) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(caption), + ) + .child( + // Track and fill are one element inside another rather than a + // gauge widget: the panel has no other progress indicator to be + // consistent with, and 3px of rounded div needs no abstraction. + div() + .w_full() + .h(px(PROGRESS_H)) + .rounded_full() + .bg(theme.border) + .child( + div() + .h_full() + .w(gpui::relative(fraction)) + .rounded_full() + .bg(accent), + ), + ) + } + /// A machine's rows, set in from its own row — and, on a *remote* machine, /// tied to it by a guide line descending from that machine's icon. /// @@ -904,8 +1018,8 @@ impl Tty7App { .top(px(0.)) // Stops short of the last row's baseline rather than // running to the edge: a line that ends level with the - // final "New Workspace" glyph reads as enclosing the - // block, one that runs past it reads as unfinished. + // final row's glyph reads as enclosing the block, one + // that runs past it reads as unfinished. .bottom(px(ROW_H / 2.)) .w(px(1.)) .bg(rail), @@ -928,6 +1042,10 @@ impl Tty7App { ); let hover = hover_fill(cx); let gref = GroupRef::of(group); + let menu_ref = gref.clone(); + let ctx_ref = gref.clone(); + let app = cx.entity().downgrade(); + let app2 = app.clone(); // A machine wears the shape of what it is, which is the only thing on // the row that says "somewhere else" before a word of it is read. Both @@ -945,6 +1063,12 @@ impl Tty7App { let (dot, word): (Option, Option<&'static str>) = match group.link { Link::Local => (None, None), Link::Connected => (Some(gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into()), None), + // "installing…" while bytes are moving: the bar underneath says how + // far along, and a header still reading "connecting…" over it would + // describe a step that finished a while ago. + Link::Connecting if group.installing.is_some() => { + (Some(theme.warning), Some("installing…")) + } Link::Connecting => (Some(theme.warning), Some("connecting…")), Link::Failed => (Some(theme.danger), Some("couldn't connect")), Link::Offline => ( @@ -1022,6 +1146,33 @@ impl Tty7App { .child(format!("{}", group.rows.len())), ) }) + // The machine's own actions, in the same `⋯` its rows use — but + // always on, where a row's appears on hover. Two reasons it earns + // the pixels a row's does not: there are a handful of machines and + // dozens of rows, so a permanent glyph here is one mark and not a + // column of them; and since "New Workspace" stopped being a row this + // is the *only* way to reach it, where a row's menu only duplicates + // what clicking the row already does. + // + // Without the `stop_propagation` the press underneath reaches the + // header and folds the machine away behind its own menu. + .child( + div() + .flex_shrink_0() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Button::new(gpui::SharedString::from(format!( + "switcher-host-more:{}", + group.key + ))) + .icon(IconName::Ellipsis) + .ghost() + .xsmall() + .dropdown_menu(move |menu, _window, _cx| { + group_menu(menu, &menu_ref, app.clone()) + }), + ), + ) .child( Icon::new(if expanded { IconName::ChevronDown @@ -1034,6 +1185,8 @@ impl Tty7App { .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { this.switcher_toggle_host(&gref, cx) })) + // Right-click reaches the same menu, exactly as a row's does. + .context_menu(move |menu, _window, _cx| group_menu(menu, &ctx_ref, app2.clone())) } fn render_row(&self, group: &Group, row: &Row, cx: &mut Context) -> AnyElement { @@ -1181,53 +1334,6 @@ impl Tty7App { .into_any_element() } - fn render_new_row(&self, group: &Group, cx: &mut Context) -> impl IntoElement + use<> { - let theme = cx.theme(); - let (muted, dim) = (theme.muted_foreground, theme.muted_foreground.opacity(0.7)); - let hover = hover_fill(cx); - let gref = GroupRef::of(group); - // The directory rides in the age column rather than inside the label. - // It still has to be *there* — on a Mac connected to a Linux box, `~` is - // `/home/them`, and a row that did not say so would only reveal it at - // the first `pwd` — but "New Workspace in /home/thomas" as one long - // sentence made the shortest row in the panel the widest. - let home = group - .home - .as_ref() - .map(|h| crate::ui::home::display_path(h)); - h_flex() - .id(gpui::SharedString::from(format!( - "switcher-new:{}", - group.key - ))) - .items_center() - .gap(px(8.)) - .h(px(ROW_H)) - .px(px(ROW_PAD)) - .rounded(px(6.)) - .cursor_pointer() - .hover(move |r| r.bg(hover)) - .text_sm() - .text_color(muted) - // A narrower column than a machine's, so the `+` centres on the - // monograms above it rather than 3px to their right. - .child(glyph_col( - ROW_AVATAR, - Icon::new(IconName::Plus).size(px(ICON)).text_color(dim), - )) - .child(div().flex_shrink_0().child("New Workspace")) - .child(div().flex_1()) - .children(home.map(|h| { - div() - .flex_shrink_0() - .truncate() - .text_xs() - .text_color(dim) - .child(h) - })) - .on_click(cx.listener(move |this, _, window, cx| this.switcher_new(&gref, window, cx))) - } - /// The machines with nothing on them yet, folded into one row. fn render_other_hosts( &self, @@ -1355,7 +1461,7 @@ impl Group { /// it names that this client has no record of becomes an extra row marked /// for adoption. Rows this client *does* have are left alone: their local /// record carries window geometry and the `open` flag, which are this - /// client's business and not the remote's (design §10's storage split). + /// client's business and not the remote's (the storage split). fn merge(&mut self, remote: &[RemoteWorkspaceRow], now: u64) { if self.target.is_none() { return; @@ -1427,6 +1533,49 @@ impl RowRef { } } +/// A machine's second-tier actions. +/// +/// "New Workspace" lives here rather than in a row of its own under every +/// machine. It was the one line in the panel that was not a workspace, it +/// repeated once per machine, and on a client with four boxes it pushed the +/// thing the panel is *for* — the list — a quarter of a card further down. +/// +/// The `⋯` is also where a machine's own verbs belong now there is more than +/// one of them: expanding a machine already means "connect", so its inverse +/// needed somewhere to be said, and it is not a row either. +fn group_menu( + menu: gpui_component::menu::PopupMenu, + group: &GroupRef, + app: gpui::WeakEntity, +) -> gpui_component::menu::PopupMenu { + let (a1, a2) = (app.clone(), app); + let gref = group.clone(); + // A remote machine can only be given a workspace once a handshake has said + // where its `$HOME` is — `~` guessed from this client would be the wrong + // directory on the wrong computer. This one needs no handshake. + let can_create = group.target.is_none() || group.home.is_some(); + let menu = menu.item( + PopupMenuItem::new("New Workspace") + .disabled(!can_create) + .on_click(move |_, window, cx| { + let _ = a1.update(cx, |this, cx| this.switcher_new(&gref, window, cx)); + }), + ); + let Some(target) = group.target.clone() else { + // This computer. There is no connection to drop, and "Disconnect" + // greyed out under every local group would only invite the question. + return menu; + }; + let connected = group.link == Link::Connected; + menu.separator().item( + PopupMenuItem::new("Disconnect") + .disabled(!connected) + .on_click(move |_, _window, cx| { + let _ = a2.update(cx, |this, cx| this.switcher_disconnect(&target, cx)); + }), + ) +} + /// A row's second-tier actions. /// /// One `⋯` rather than a cluster of glyphs, and the destructive one behind it @@ -1501,8 +1650,8 @@ fn hover_fill(cx: &App) -> gpui::Rgba { /// Every icon in the panel goes through this, which is the whole point: the old /// layout let each row start its own glyph wherever its padding happened to /// land, so nothing shared a vertical axis. A machine's column is [`GUTTER`]; -/// the rows underneath use [`ROW_AVATAR`], so a `+` lands on the monograms -/// above it rather than beside them. +/// the rows underneath use the narrower [`ROW_AVATAR`], so their monograms +/// share one axis of their own rather than sitting 3px off the machines'. fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement { div() .w(px(w)) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 3bda5060..17128354 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -1057,7 +1057,7 @@ impl Tty7App { // path, and that is correct rather than a shortcut: a tab // belongs to one workspace, a workspace names one machine in // `Workspace.host`, and a window shows one workspace — design - // §3 rules out ever mixing local and remote in one window. So + // Mixing local and remote in one window never happens. So // the qualified key is `(workspace.host_id(), sidebar_group)` // with the host half held once per workspace instead of once // per tab, and two machines can't collide here without a diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 30808044..b3951904 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -21,7 +21,7 @@ use crate::core::actions::{ SelectWorkspace5, SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, TogglePalette, }; -use crate::core::config::{Config, RightPanelTab}; +use crate::core::config::RightPanelTab; use crate::daemon::protocol::ShellSpec; use crate::ui::app::{TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, Tab, Tty7App, tile_trailing_inset}; use crate::ui::hints::tab_badge_label; @@ -882,10 +882,10 @@ impl Tty7App { } } - /// Attach the "new tab" shell picker to a button: the configured default - /// shell leads the menu (tagged `default`), followed by every shell detected - /// on this machine; clicking one opens a tab on that shell. Extracted so the - /// title-bar strip's "+" and the vertical [`tab_sidebar`] share one menu + /// Attach the "new tab" shell picker to a button: the default shell leads + /// the menu (tagged `default`), followed by every shell found on **this + /// window's machine**; clicking one opens a tab on that shell. Extracted so + /// the title-bar strip's "+" and the vertical [`tab_sidebar`] share one menu /// definition rather than duplicating the shell iteration. /// /// [`tab_sidebar`]: crate::ui::tab_sidebar @@ -894,13 +894,8 @@ impl Tty7App { button: Button, cx: &Context, ) -> impl IntoElement + use<> { - let shells = self.detected_shells.clone(); - let default_name = crate::core::shells::default_shell_name( - cx.global::() - .shell - .as_ref() - .map(|s| s.program.as_str()), - ); + let shells = self.shells.shells.clone(); + let default_name = self.default_shell_label(cx); let app = cx.entity().downgrade(); button.dropdown_menu(move |menu, _window, _cx| { let mut menu = menu.min_w(px(220.)); diff --git a/src/ui/windows.rs b/src/ui/windows.rs index c8e6e640..f56e231d 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -457,6 +457,21 @@ fn confirm_destructive( /// Callers confirm first unless [`live_pane_count`] answered zero; with nothing /// running there is nothing to lose. pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) { + stop_workspace_keeping(cx, workspace, ClearedLayout::Push); +} + +/// What to do with the record once its panes are dead. +#[derive(Clone, Copy, PartialEq)] +enum ClearedLayout { + /// Send it to the machine that owns it — the workspace is going to be + /// reopened, and it must not reopen claiming panes that no longer exist. + Push, + /// Leave it alone: the caller is about to delete the record outright, and a + /// push racing that delete could put the workspace back on the machine. + Discard, +} + +fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) { // A remote workspace's panes live on the remote server, and its pane ids are // *that* daemon's. Sending them here would not fail — it would succeed // against whatever local panes happen to hold those numbers, killing a @@ -496,7 +511,7 @@ pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) { cache.invalidate(host) }); } - // Design §15: a remote workspace's port forwards are owned by the + // A remote workspace's port forwards are owned by the // *workspace*, not by its panes, so nothing else ends them. Done before the // window closes, because the route to the daemon is read off a live pane. if let Some(app) = WindowRegistry::app_for(cx, workspace) @@ -509,9 +524,56 @@ pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) { // half-finished action. close_window_for(cx, workspace); WorkspaceStore::close_window(cx, workspace); + // Last, and after the window is gone so nothing records the old layout back + // over it: the ids we just killed are dead by our own hand, and a record + // that still claims them reopens into panes that cannot be attached to. + // Locally that is invisible (`alive_panes_on` asks the daemon and gets the + // same answer); on a remote workspace nobody asks, so the stale id is the + // whole difference between reopening onto fresh shells with the agent + // conversation resumed and reopening onto `tty7 — disconnected`. + forget_killed_panes(cx, workspace, cleared); refresh_menu(cx); } +/// Drop `workspace`'s pane ids, and tell the machine that owns the record. +/// +/// The push is not optional for a remote workspace that is being kept: design +/// The remote's `workspaces.json` is the authority, so reopening pulls +/// its copy over the client's ([`WorkspaceStore::apply_remote`]) and a +/// local-only edit would be undone by the next open — which is the open this +/// exists for. +fn forget_killed_panes(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) { + if !WorkspaceStore::forget_pane_ids(cx, workspace) { + return; + } + if cleared == ClearedLayout::Discard { + return; + } + let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, workspace) else { + return; + }; + let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else { + // Not connected, so the panes were not killed either — `kill_pane_on` + // needs the same route. The client's copy is still worth clearing: it + // is what a reconnect pushes back up. + log::info!( + "ended sessions on {} without reaching it; the cleared layout goes up on reconnect", + host.target + ); + return; + }; + cx.background_executor() + .spawn(async move { + if let Err(e) = crate::ui::remote_connect::put_remote_layout(&connection, key, record) { + log::warn!( + "could not tell {} its workspace's panes are gone: {e}", + host.target + ); + } + }) + .detach(); +} + /// Delete a workspace outright: stop it, then forget it entirely. Irreversible /// — nothing about the layout survives. pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) { @@ -519,13 +581,16 @@ pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) { // still on file. Doing this after `WorkspaceStore::remove` would leave the // record stranded on the remote with no way left to name it. delete_on_remote(cx, workspace); - stop_workspace(cx, workspace); + // …and the stop that follows must not push the emptied layout back up: the + // delete above is in flight on a background task, and a push landing after + // it would recreate the record it just removed. + stop_workspace_keeping(cx, workspace, ClearedLayout::Discard); WorkspaceStore::remove(cx, workspace); release_unused_hosts(cx); refresh_menu(cx); } -/// Forget a remote workspace on the machine that owns it (design §10: the +/// Forget a remote workspace on the machine that owns it (the /// remote's `workspaces.json` is the authority, so deleting only the client's /// pointer would leave the workspace there and reappear on the next connect). ///