From bed22d899e7180a40744234b3c0c91b13de7d94c Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Wed, 29 Jul 2026 19:15:19 +0800 Subject: [PATCH] Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(remote): keep a remote workspace whole across reopens and restarts Reopening a remote workspace — or coming back to one whose `tty7-server` had been replaced — landed on a screen of `tty7 — disconnected` panes with their coding-agent conversations gone. Several independent holes added up to that; this closes them together, and picks up the surrounding work the same session produced. **Telling a restarted server from a blinked link.** `ControlHelloOk` now carries an `instance` minted once per server *process*. Nothing else in the handshake changes across a restart — `build` and both dialect numbers survive it — so a reconnect had no way to know its `pane_id`s were dead. It does now: a different instance rebuilds the window from its layout (same tabs and splits, fresh shells in the saved cwds) instead of re-attaching to a process that is gone. An absent instance means *unknown* and is never read as a restart. **An attach can now fail.** `Attach` has no synchronous reply, so the client returned `Ok` unconditionally and the daemon's `Error` frame was read much later by the reader thread, which has no arm for it — the pane then landed in the *link is down* state instead of falling back to a fresh shell. The client now reads far enough into the reply to classify it on the kind byte (the snapshot behind it can be megabytes) and hands those bytes to the reader thread, so a successful attach loses none of its replay. Local and remote attaches get different waits: the local one is on the UI thread. **The agent session survives to be resumed.** `TerminalView` raises `AgentSessionChanged` when the pane's agent reports a new native session id, so the layout on file catches up instead of waiting for the user to happen to open a tab. A pane that is still connecting now carries its agent through `PendingSpawn` — a save landing in that window used to write `agent: null` over the record — and `land_pane` sends `--resume` when the attach turned out to need a fresh shell. **Ending sessions says so on file.** "End Sessions" kills the panes and then drops their ids from the record, pushing the cleared layout to the machine that owns it (design §10: the remote's copy wins, so a local-only clear would be undone by the next open — the open this exists for). **The new-tab dropdown lists the window's machine.** `Host::shells` and a `Shells` control request (dialect v2) make the "+" menu a property of the machine the window is bound to. A remote window filled from this computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is elsewhere, and every pick failed to spawn. **An install reports its bytes.** The download and the SFTP upload each report progress, relayed to the client over the routed connection as a `RoutePrompt::InstallProgress`, and painted as a bar under the machine's row in the switcher. ~8 MB across two hops behind the word "connecting…" was indistinguishable from a hang. **The installer compares dialects, not version strings.** `tty7-server --protocol` prints what a binary speaks without starting it, so a connect adopts an already-running server it can talk to rather than prompting about a build difference and uploading 8 MB the machine did not need. **Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row under every machine, pushing the list a quarter of a card down) and a new "Disconnect", which drops the connection and leaves the windows open and read-only. The suspension lasts exactly as long as that machine has a window on it. Also drops three design/contract docs for the now-shipped remote-workspace work. * fix(session): stop one workspace's panes from being restored into another A restart put a copy of one workspace's seven tabs — cwds, layout and recorded agent sessions — in front of another workspace's own tabs, and auto-resumed every one of those agents a second time: six `claude --resume ` pairs running in parallel against the same conversations, one set per window. The record-level corruption that seeded it is still unattributed, but every mechanism that let it propagate, amplify, or go unnoticed is closable, and this closes them. **Panes now know their owner.** `Spawn` can carry the workspace the pane is created for; the daemon stores it immutably and reports it in `List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another workspace owns (`pane_attachable`) — before this, a saved id landing on somebody else's live pane attached silently, which is how one window could pick up another's shells. The field rides a new `SPAWN_OWNED` frame with a struct payload (the legacy spawn payloads are positional tuples an old daemon cannot grow), gated on a new `pane-owner` feature string: a client only sends it to a daemon that advertises it, so the legacy kinds stay byte-for-byte what old daemons expect. A pane with no recorded owner stays attachable by anyone — that is the pre-field behavior, not a new risk. **Saved pane ids are bound to the daemon process that issued them.** `DaemonVersion` now carries an `instance` minted once per process (the local twin of the control hello's), the GUI caches it at the `ensure_running` handshake, and each local workspace records it as `daemon_instance` beside its layout. Claiming a workspace whose ids came from a different instance blanks them first: daemon pane ids restart from 1, so after a reboot every saved id points at whatever unrelated shell holds the number now, and the aliveness check cannot tell a survivor from a squatter. A blank on either side means "cannot tell" and never trips it. Unlike the duplicate-claim case below, this path keeps the agent resume — the pane is genuinely gone with its daemon, and the fresh shell resuming the conversation is the feature. **A duplicate claim loses its agent resume along with its pane id.** `dedupe_pane_ids` kept the loser's layout *and* its `agent_session_id`, so the blanked leaves took restore's spawn-fresh path and auto-typed `claude --resume` for conversations the winning workspace's panes were still running — the doubling above. The winner keeps the panes and the resume; the loser keeps only cwds. **Cross-workspace saves are caught at the write.** Every terminal view remembers the workspace whose window created it, and `save_session` logs an error naming both ids if a window ever records a pane created for a different workspace — the tripwire for the still-unattributed seed corruption, so a recurrence is caught in the act instead of reconstructed from `session.json` archaeology days later. Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance` and `Workspace.daemon_instance` are `#[serde(default)]` struct fields (old peers' JSON decodes, new fields are ignored by old readers), and `SPAWN_OWNED` is feature-gated as above. `daemon_instance` is client-owned in the design-§10 storage split — it names the local daemon, and the field-census test pins the classification. * fix(session): resume the agent when a local pane dies mid-restore `session_to_pane` decided whether to send a coding agent's `--resume` from `restore.is_none()` — i.e. from whether the pane looked alive when the restore started. But `alive_panes_on` runs one `List` at the top of the restore, while the attaches happen per leaf afterwards. A pane that exited in between failed its attach, fell back to a fresh shell inside `spawn_shell_terminal_in`, and then landed in the `restore.is_some()` arm: an empty shell with its conversation dropped. `ShellParts.restored` already answers this exactly, and the remote path already reads it in `land_pane`. Carry it onto `TerminalView` so the synchronous local path can read it too, and branch on that instead of re-deriving the answer from a set that may be stale by the time it is used. No behaviour change on the paths that were already correct: a view that was never restoring anything reports `restored: false`, which is the same answer `restore.is_none()` gave them. * fix(remote): check the server instance against the record, not just memory A remote workspace's pane ids were only guarded against server restarts by `RemoteLinks::instances`, an in-memory map. On the first connect after the client starts, every machine is a first sighting, so `server_restarted` answers false — and a `tty7-server` that was replaced while the client was closed sails straight through. Its pane ids restart from 1, so the saved ones now name unrelated shells, and the reconnect attaches to them: the exact id-reuse failure the local side already guards against. `Workspace::daemon_instance` was local-only for the stated reason that a remote server's identity is tracked live per connection. That tracking is correct but not sufficient — it cannot survive the client restart that makes the question worth asking. So the field now means the same thing on both sides: which process minted the pane ids in this record. `WorkspaceStore::serving_instance` picks the local daemon or the far machine's server depending on the workspace, and `finish_attempt` compares it per workspace before deciding to re-attach or rebuild. It stays client-owned: it records what *this* client last saw, so two clients on one remote workspace each keep their own and neither may overwrite the other's. An unreachable machine still records nothing, which is what keeps a good stamp from being erased with `None` — that would disarm the next check. Also in these three files: the §N references to the deleted design docs, cleaned up as part of the sweep in the following commit. * docs: drop the references to the deleted design documents The three documents this branch removed were cited ~280 times: `design §10`, `contract §8`, `§17` and friends in comments, five references by file path in code and manifests, five in CI workflows and one in the release skill. Every one of them now points at nothing. Rewritten rather than merely stripped, because most were not decoration: "design §10 makes the remote's `workspaces.json` the authority" becomes a statement in its own right, and the several that carried a Chinese phrase from the document as their justification say the same thing in English instead. Where the reference was purely parenthetical it is simply gone. Not touched: `PRD §7.1`, `brief §8` and the like, which name documents this branch did not remove and were already external before it, and the `RFC 4648 §10` test-vector citation, which is a real specification. The `host boundary` CI job loses `(§10.6)` from its name. It is not one of the required checks, so branch protection is unaffected. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .github/scripts/assert-static.sh | 4 +- .github/scripts/bundle-windows.ps1 | 2 +- .github/scripts/check-host-boundary.sh | 6 +- .github/workflows/ci.yml | 15 +- .github/workflows/nightly.yml | 4 +- .github/workflows/release.yml | 17 +- Cargo.toml | 5 +- crates/tty7-core/Cargo.toml | 6 +- crates/tty7-core/src/core/agent_hooks.rs | 3 + crates/tty7-core/src/core/keychain.rs | 2 +- crates/tty7-core/src/core/session.rs | 305 +++- crates/tty7-core/src/core/shells.rs | 39 +- crates/tty7-core/src/core/workspace_store.rs | 8 +- crates/tty7-core/src/daemon/control.rs | 110 +- crates/tty7-core/src/daemon/install/asset.rs | 40 +- .../tty7-core/src/daemon/install/checksums.rs | 16 +- .../tty7-core/src/daemon/install/download.rs | 54 +- crates/tty7-core/src/daemon/install/mod.rs | 463 +++++- .../tty7-core/src/daemon/install/ssh_ops.rs | 13 +- crates/tty7-core/src/daemon/install/tests.rs | 524 ++++++- crates/tty7-core/src/daemon/install/wsl.rs | 19 +- crates/tty7-core/src/daemon/pane.rs | 15 + crates/tty7-core/src/daemon/protocol.rs | 236 ++- crates/tty7-core/src/daemon/remote_link.rs | 7 +- crates/tty7-core/src/daemon/router.rs | 77 +- crates/tty7-core/src/daemon/server.rs | 11 +- crates/tty7-core/src/daemon/spawn.rs | 56 +- crates/tty7-core/src/daemon/ssh/forward.rs | 10 +- crates/tty7-core/src/daemon/ssh/mod.rs | 6 +- crates/tty7-core/src/daemon/ssh/session.rs | 4 +- crates/tty7-core/src/daemon/ssh/sftp.rs | 14 +- crates/tty7-core/src/daemon/ssh/workspace.rs | 4 +- crates/tty7-core/src/host/conformance.rs | 36 + crates/tty7-core/src/host/local.rs | 9 +- crates/tty7-core/src/host/mod.rs | 20 +- crates/tty7-core/src/host/remote.rs | 41 +- crates/tty7-core/src/host/server.rs | 28 +- crates/tty7-core/src/lib.rs | 2 +- crates/tty7-server/Cargo.toml | 2 +- crates/tty7-server/src/main.rs | 31 +- crates/tty7-server/tests/cli.rs | 2 +- crates/tty7-server/tests/remote_router.rs | 2 +- crates/tty7-server/tests/routed_pane.rs | 2 + crates/tty7-server/tests/stdio_conformance.rs | 2 +- crates/tty7-server/tests/workspace_store.rs | 8 +- docs/2026-07-27-remote-workspace-design.md | 430 ----- ...26-07-27-remote-workspace-impl-contract.md | 1380 ----------------- docs/remote-server-assets.md | 122 -- src/core/session.rs | 325 +++- src/terminal/remote.rs | 323 +++- src/terminal/view.rs | 238 ++- src/ui/app.rs | 575 +++++-- src/ui/code_editor.rs | 8 +- src/ui/file_tree.rs | 10 +- src/ui/home.rs | 8 +- src/ui/mod.rs | 4 +- src/ui/pending_pane.rs | 26 +- src/ui/remote_connect.rs | 90 +- src/ui/remote_workspace.rs | 638 +++++++- src/ui/right_panel.rs | 17 +- src/ui/settings.rs | 4 +- src/ui/sftp.rs | 8 +- src/ui/ssh_prompt.rs | 2 +- src/ui/switcher.rs | 277 +++- src/ui/tab_sidebar.rs | 2 +- src/ui/tab_strip.rs | 19 +- src/ui/windows.rs | 71 +- 67 files changed, 4340 insertions(+), 2517 deletions(-) delete mode 100644 docs/2026-07-27-remote-workspace-design.md delete mode 100644 docs/2026-07-27-remote-workspace-impl-contract.md delete mode 100644 docs/remote-server-assets.md 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). ///