mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* 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 <id>` 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>
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
# Fail unless the binary is a fully static ELF — no dynamic loader, no shared
|
# Fail unless the binary is a fully static ELF — no dynamic loader, no shared
|
||||||
# library dependencies.
|
# library dependencies.
|
||||||
#
|
#
|
||||||
# This is the mechanical guard behind D10 (docs/2026-07-27-remote-workspace-design.md):
|
# This is the mechanical guard behind decision D10: one `tty7-server` binary
|
||||||
# one `tty7-server` binary is pushed to arbitrary remote machines and must run
|
# is pushed to arbitrary remote machines and must run
|
||||||
# there regardless of what libc, and what *version* of it, that machine has. A
|
# 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
|
# 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 —
|
# compile-only CI job and then fail on the first old box a user connects to —
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Copy-Item LICENSE "$Stage/LICENSE.txt"
|
|||||||
Copy-Item README.md "$Stage/README.md"
|
Copy-Item README.md "$Stage/README.md"
|
||||||
|
|
||||||
# The Linux musl `tty7-server`, staged at server/ so a WSL distro can be handed
|
# 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
|
# lookup path is a contract with `daemon::install::wsl` — it searches
|
||||||
# <dir of tty7.exe>/server/<asset> first — so this directory name is not free to
|
# <dir of tty7.exe>/server/<asset> first — so this directory name is not free to
|
||||||
# change on its own. Missing is a warning, not an error, matching `server-musl`'s
|
# change on its own. Missing is a warning, not an error, matching `server-musl`'s
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# 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
|
# `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
|
# `canonicalize` walks the local filesystem, `is_absolute` says `false` for
|
||||||
# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may
|
# `/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`
|
# 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'
|
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
|
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`
|
calls answer for the wrong machine. Route it through `ui::host_ops` / `Host`
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ jobs:
|
|||||||
components: rustfmt
|
components: rustfmt
|
||||||
- run: cargo fmt --check
|
- 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
|
# 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.
|
# script, next to the reason each one is exempt.
|
||||||
#
|
#
|
||||||
# A standalone job on purpose, and one that must stay *non-required*: main's
|
# 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
|
# `server-musl` below. Cheap enough (a checkout and a grep) that it does not
|
||||||
# need caching or a toolchain.
|
# need caching or a toolchain.
|
||||||
host-boundary:
|
host-boundary:
|
||||||
name: host boundary (§10.6)
|
name: host boundary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
run: cargo test --locked --target ${{ matrix.target }}
|
run: cargo test --locked --target ${{ matrix.target }}
|
||||||
|
|
||||||
# Static musl builds of the headless server binary that remote workspaces push
|
# 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
|
# 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
|
# version, so it is linked fully static against musl rather than built per
|
||||||
# distro. Compile-only — this job publishes nothing; release.yml and
|
# 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
|
# Deliberately a *separate* job rather than two more rows in the `build` matrix
|
||||||
# above: those three `build & test (<target>)` names are main's required
|
# above: those three `build & test (<target>)` names are main's required
|
||||||
# checks, and reshaping that matrix would wedge branch protection on every open
|
# 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
|
# PR. Keep this job non-required until it has a few weeks of green.
|
||||||
# docs/remote-server-assets.md.
|
|
||||||
server-musl:
|
server-musl:
|
||||||
name: tty7-server musl (${{ matrix.target }})
|
name: tty7-server musl (${{ matrix.target }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -153,7 +152,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
key: ${{ matrix.target }}
|
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
|
# 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
|
# 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
|
# once it arrives. `--no-deps` keeps this to a manifest parse — no
|
||||||
@@ -167,7 +166,7 @@ jobs:
|
|||||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
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
|
fi
|
||||||
|
|
||||||
# `-p tty7-server` addresses the package by name, so this survives whatever
|
# `-p tty7-server` addresses the package by name, so this survives whatever
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Mirrors release.yml's server-musl job; keep the two in sync when editing.
|
# 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.
|
# be exercised against the rolling channel instead of waiting for a tag.
|
||||||
server-musl:
|
server-musl:
|
||||||
needs: plan
|
needs: plan
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
path: dist
|
path: dist
|
||||||
merge-multiple: true
|
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
|
# 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.
|
# other, and so the prune step at the end sees it as current.
|
||||||
- name: Generate checksums.txt
|
- name: Generate checksums.txt
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
# The Windows installer embeds the Linux musl `tty7-server` so a WSL distro
|
# 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
|
# 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
|
# 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
|
# 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
|
# a release — cheap next to splitting the Windows entry into its own job and
|
||||||
@@ -150,10 +150,11 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# The headless server binary remote workspaces install on the far machine
|
# 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
|
# 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
|
# 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
|
# 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.
|
# GUI toolchain, no bundling, no code signing, two targets off one runner.
|
||||||
@@ -193,7 +194,7 @@ jobs:
|
|||||||
workspaces: tty7
|
workspaces: tty7
|
||||||
key: ${{ matrix.target }}
|
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
|
# rather than fail, so this workflow can ship ahead of the split; the
|
||||||
# release simply carries no server assets until it arrives.
|
# release simply carries no server assets until it arrives.
|
||||||
- name: Look for the tty7-server package
|
- 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"
|
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
|
# 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.
|
# installer derives this name from.
|
||||||
- name: Stage the asset
|
- name: Stage the asset
|
||||||
if: steps.probe.outputs.present == 'true'
|
if: steps.probe.outputs.present == 'true'
|
||||||
@@ -262,12 +263,12 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
# sha256 over every asset, so the remote-server installer can verify what
|
# sha256 over every asset, so the remote-server installer can verify what
|
||||||
# it downloaded before writing it to someone else's machine (design §16 —
|
# it downloaded before writing it to someone else's machine (a mismatch
|
||||||
# a mismatch aborts the install outright). Generated here rather than in
|
# aborts the install outright). Generated here rather than in
|
||||||
# the build jobs because only this job sees the complete asset set, and a
|
# 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
|
# per-job fragment would have to be concatenated in a deterministic order
|
||||||
# anyway. GNU coreutils format ("<hex> <name>"), bare filenames, sorted —
|
# anyway. GNU coreutils format ("<hex> <name>"), 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
|
- name: Generate checksums.txt
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
+2
-3
@@ -16,8 +16,7 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# The framework-free half of tty7: wire protocol, session daemon, PTY, the
|
# 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
|
# native SSH engine, and the domain model the headless `tty7-server` shares with
|
||||||
# this GUI. Everything that does *not* need gpui lives there — see
|
# this GUI. Everything that does *not* need gpui lives there.
|
||||||
# `docs/2026-07-27-remote-workspace-design.md` §11.
|
|
||||||
# `gssapi` is off in tty7-core's defaults (a static musl `tty7-server` cannot
|
# `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
|
# link the system krb5 it binds); the GUI, which builds against a real desktop
|
||||||
# toolchain, turns it on so managed SSH connections keep offering
|
# 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
|
# `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
|
# 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.
|
# never take that path; the GUI, which is the client that pushes it, can.
|
||||||
tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] }
|
tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] }
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ publish = false
|
|||||||
# The whole point of this crate is that it does *not* depend on gpui. Everything
|
# 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
|
# 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
|
# `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]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.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 /
|
# unchanged. It has to be optional because `libgssapi` binds the *system* MIT /
|
||||||
# Heimdal krb5 through bindgen: a machine without krb5 headers cannot build it
|
# 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
|
# 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
|
# circumstances. Without the feature, `SshAuthMode::Gssapi` reports that the
|
||||||
# method is unavailable in this build and the other auth families are untouched.
|
# 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 = []
|
default = []
|
||||||
gssapi = ["dep:libgssapi"]
|
gssapi = ["dep:libgssapi"]
|
||||||
# Lets this build download a `tty7-server` release asset over HTTPS and push it
|
# 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
|
# 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
|
# 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
|
# handed and still launches/probes a remote daemon; only the fetch fails, with a
|
||||||
|
|||||||
@@ -1513,6 +1513,9 @@ mod tests {
|
|||||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<crate::host::Output> {
|
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<crate::host::Output> {
|
||||||
self.0.git(cwd, args)
|
self.0.git(cwd, args)
|
||||||
}
|
}
|
||||||
|
fn shells(&self) -> io::Result<crate::host::ShellInventory> {
|
||||||
|
self.0.shells()
|
||||||
|
}
|
||||||
fn watch(&self, dirs: &[PathBuf]) -> io::Result<crate::host::WatchSub> {
|
fn watch(&self, dirs: &[PathBuf]) -> io::Result<crate::host::WatchSub> {
|
||||||
self.0.watch(dirs)
|
self.0.watch(dirs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on
|
//! `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
|
//! 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
|
//! 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`
|
//! What has to stay is exactly what `Config` needs to parse `config.json`
|
||||||
//! identically on the server: the account-naming scheme and [`CredentialRef`].
|
//! identically on the server: the account-naming scheme and [`CredentialRef`].
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ pub struct SessionTab {
|
|||||||
/// is deliberately not persistable. The qualifier is not missing, it is
|
/// is deliberately not persistable. The qualifier is not missing, it is
|
||||||
/// factored out: a tab always belongs to exactly one [`Workspace`], a
|
/// factored out: a tab always belongs to exactly one [`Workspace`], a
|
||||||
/// workspace names exactly one machine in [`Workspace::host`], and a
|
/// workspace names exactly one machine in [`Workspace::host`], and a
|
||||||
/// window shows exactly one workspace (design §2, and §3's "一个窗口里既
|
/// window shows exactly one workspace — mixing local and remote tabs in one
|
||||||
/// 有本地又有远程 —— 这个**永远不做**"). So the fully-qualified group key
|
/// window is the thing tty7 never does. So the fully-qualified group key
|
||||||
/// is `(workspace.host_id(), tab.sidebar_group)`, with the host half
|
/// is `(workspace.host_id(), tab.sidebar_group)`, with the host half
|
||||||
/// stored once per workspace instead of once per tab. Two machines whose
|
/// 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
|
/// 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
|
/// The machine a remote workspace lives on, named the way the user already
|
||||||
/// named it.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// three variants are the three places an SSH target can already have been
|
||||||
/// spelled out in tty7 today.
|
/// 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:<uuid>` |
|
/// | [`Profile`](RemoteTarget::Profile) | A saved [`SshProfile`](crate::core::ssh_profile::SshProfile), by its stable uuid | `ssh-profile:<uuid>` |
|
||||||
/// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:<alias>` |
|
/// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:<alias>` |
|
||||||
@@ -192,7 +192,7 @@ pub enum RemoteTarget {
|
|||||||
},
|
},
|
||||||
/// A WSL distribution. **M8 owns the behaviour**; the variant exists now so
|
/// A WSL distribution. **M8 owns the behaviour**; the variant exists now so
|
||||||
/// that [`connection_key`](RemoteTarget::connection_key) is a total function
|
/// 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 },
|
Wsl { distro: String },
|
||||||
/// A `tty7-server --stdio` child process on *this* machine — the workspace
|
/// A `tty7-server --stdio` child process on *this* machine — the workspace
|
||||||
/// mirror of [`RouteTarget::LocalStdio`](crate::daemon::router::RouteTarget::LocalStdio),
|
/// 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,
|
/// **Contains no workspace id.** Several workspaces on one box share a key,
|
||||||
/// and therefore share a [`HostId`](crate::host::HostId) and the one SSH
|
/// and therefore share a [`HostId`](crate::host::HostId) and the one SSH
|
||||||
/// connection underneath it — the granularity the whole design assumes
|
/// connection underneath it — the granularity the whole design assumes.
|
||||||
/// (design §10).
|
|
||||||
///
|
///
|
||||||
/// One conservative case worth knowing: `me@box` and a bare `box` are
|
/// 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
|
/// 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`
|
/// into that machine's `~/.local/share/tty7/workspaces.json`
|
||||||
/// ([`crate::core::workspace_store`]). A client-side [`Workspace`] carrying one
|
/// ([`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
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub struct RemoteRef {
|
pub struct RemoteRef {
|
||||||
/// Which machine, in terms of a configuration that already exists.
|
/// 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
|
/// 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**:
|
/// one, **and means it identically to every build that predates the field**:
|
||||||
/// a `session.json` written before this existed decodes with `None`
|
/// 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
|
/// 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
|
/// `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.
|
/// workspace from the laptop at home.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub host: Option<RemoteRef>,
|
pub host: Option<RemoteRef>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Workspace {
|
impl Default for Workspace {
|
||||||
@@ -402,6 +427,7 @@ impl Default for Workspace {
|
|||||||
open: true,
|
open: true,
|
||||||
last_active: now_secs(),
|
last_active: now_secs(),
|
||||||
host: None,
|
host: None,
|
||||||
|
daemon_instance: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,6 +514,48 @@ impl Workspace {
|
|||||||
out
|
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.
|
/// Stamp this workspace as just-focused.
|
||||||
pub fn touch(&mut self) {
|
pub fn touch(&mut self) {
|
||||||
self.last_active = now_secs();
|
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
|
/// The record the **remote** owns, as the JSON that crosses the wire in a
|
||||||
/// [`WorkspacePut`](crate::daemon::control::ControlRequest::WorkspacePut).
|
/// [`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 —
|
/// 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.
|
/// 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
|
/// [`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.
|
/// 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
|
/// 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"];
|
pub const REMOTE_OWNED_FIELDS: &[&str] = &["id", "name", "session", "last_active"];
|
||||||
|
|
||||||
/// The client-side view state, which stays in this machine's `session.json`.
|
/// 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.
|
/// 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<u64>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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,
|
/// 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 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(
|
fn drop_duplicate_pane_ids(
|
||||||
pane: &mut SessionPane,
|
pane: &mut SessionPane,
|
||||||
seen: &mut std::collections::HashSet<u64>,
|
seen: &mut std::collections::HashSet<u64>,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
match pane {
|
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) => {
|
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;
|
*pane_id = None;
|
||||||
|
*agent_session_id = None;
|
||||||
|
*agent_launch_argv = None;
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
_ => 0,
|
_ => 0,
|
||||||
@@ -1116,6 +1215,110 @@ mod tests {
|
|||||||
assert_eq!(back.active, Some(id));
|
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]
|
#[test]
|
||||||
fn display_name_prefers_user_name_then_repo_then_cwd() {
|
fn display_name_prefers_user_name_then_repo_then_cwd() {
|
||||||
// No name, no repo group: fall back to the first leaf's directory.
|
// 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 <id>`, 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
|
/// 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
|
/// 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 —
|
/// 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);
|
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
|
/// wire contract in all but name: change one and every workspace on that
|
||||||
/// machine gets a different `HostId` than the connection pool minted.
|
/// machine gets a different `HostId` than the connection pool minted.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1540,7 +1808,7 @@ mod tests {
|
|||||||
assert_eq!(host.target.connection_key(), "ssh-direct:me@box.local:2222");
|
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
|
/// 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
|
/// `to_remote_json` and lost on the next pull, which is data loss that no
|
||||||
/// other test would notice.
|
/// other test would notice.
|
||||||
@@ -1560,6 +1828,9 @@ mod tests {
|
|||||||
},
|
},
|
||||||
WorkspaceId::new(),
|
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 value = serde_json::to_value(&ws).unwrap();
|
||||||
let mut present: Vec<String> = value
|
let mut present: Vec<String> = value
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ use std::path::Path;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// One launchable shell surfaced in the new-tab dropdown. `program` + `args`
|
/// One launchable shell surfaced in the new-tab dropdown. `program` + `args`
|
||||||
/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a
|
/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a
|
||||||
/// bare name resolved via `PATH` or an absolute path, plus launch arguments.
|
/// 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 {
|
pub struct DetectedShell {
|
||||||
/// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`.
|
/// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`.
|
||||||
pub label: String,
|
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<DetectedShell>,
|
||||||
|
/// 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
|
/// Enumerate the shells installed on this machine, best-effort. Order is
|
||||||
/// meaningful: the entry most likely to be the user's default comes first.
|
/// 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.
|
/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread.
|
||||||
|
|||||||
@@ -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.
|
//! `~/.local/share/tty7/workspaces.json`, and the one writer to it.
|
||||||
//!
|
//!
|
||||||
//! # Which half of the split this is
|
//! # Which half of the split this is
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
//! [`Workspace`](crate::core::session::Workspace). The server is a store, not a
|
//! [`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
|
//! 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
|
//! 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
|
//! 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
|
//! write — silent data loss whose only symptom is a setting that will not
|
||||||
//! stick.
|
//! stick.
|
||||||
@@ -96,7 +96,7 @@ const MAX_ID_BYTES: usize = 128;
|
|||||||
|
|
||||||
/// Who is currently attached to a workspace.
|
/// 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
|
/// 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
|
/// 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
|
/// 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 |
|
/// | 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 |
|
/// | 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
|
/// 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
|
/// *client's* view state, and a box that is both someone's laptop and someone
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ use std::collections::HashMap;
|
|||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::mpsc::{RecvTimeoutError, SyncSender, sync_channel};
|
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 std::time::{Duration, Instant};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
/// independent of [`crate::daemon::protocol::PROTOCOL_VERSION`]: the pane
|
||||||
/// protocol and the control dialect evolve on separate clocks, and a remote
|
/// protocol and the control dialect evolve on separate clocks, and a remote
|
||||||
/// `tty7-server` speaks control without necessarily serving panes at all.
|
/// `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<String> = OnceLock::new();
|
||||||
|
INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Paths coalesced into one [`ControlEvent::Watch`] window before the server
|
/// Paths coalesced into one [`ControlEvent::Watch`] window before the server
|
||||||
/// gives up on precision and sends [`ControlEvent::WatchOverflow`] instead,
|
/// gives up on precision and sends [`ControlEvent::WatchOverflow`] instead,
|
||||||
@@ -168,6 +205,10 @@ pub mod feature {
|
|||||||
// mistranslation rather than a compile error.
|
// mistranslation rather than a compile error.
|
||||||
pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
|
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
|
// Requests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -267,6 +308,12 @@ pub enum ControlRequest {
|
|||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----- 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 ------------------------------------------------------------
|
// ----- watch ------------------------------------------------------------
|
||||||
/// Open a subscription; the server answers with a [`ReplyOk::WatchId`].
|
/// Open a subscription; the server answers with a [`ReplyOk::WatchId`].
|
||||||
WatchOpen {
|
WatchOpen {
|
||||||
@@ -294,7 +341,7 @@ pub enum ControlRequest {
|
|||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ----- attachment (M6's takeover, design §10) ---------------------------
|
// ----- attachment (M6's takeover) ---------------------------------------
|
||||||
/// Claim a workspace for this connection's session, taking it over from
|
/// Claim a workspace for this connection's session, taking it over from
|
||||||
/// whoever held it. The server answers
|
/// whoever held it. The server answers
|
||||||
/// [`ReplyOk::Attached`] and pushes [`ControlEvent::Preempted`] to the
|
/// [`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
|
/// conservative timeout would make the fast paths feel broken; a single
|
||||||
/// aggressive one would break the slow paths.
|
/// 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
|
/// `TimedOut`, a [`kind::CANCEL`] goes out, and every other in-flight
|
||||||
/// request is untouched.
|
/// request is untouched.
|
||||||
pub fn deadline(&self) -> Duration {
|
pub fn deadline(&self) -> Duration {
|
||||||
@@ -347,6 +394,10 @@ impl ControlRequest {
|
|||||||
Duration::from_secs(10)
|
Duration::from_secs(10)
|
||||||
}
|
}
|
||||||
Git { .. } | Search { .. } => Duration::from_secs(20),
|
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 { .. } => {
|
WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => {
|
||||||
Duration::from_secs(10)
|
Duration::from_secs(10)
|
||||||
}
|
}
|
||||||
@@ -412,11 +463,13 @@ pub enum ReplyOk {
|
|||||||
Hits(Vec<SearchHit>),
|
Hits(Vec<SearchHit>),
|
||||||
Output(Output),
|
Output(Output),
|
||||||
WatchId(u64),
|
WatchId(u64),
|
||||||
|
/// [`ControlRequest::Shells`]: what that machine can launch.
|
||||||
|
Shells(ShellInventory),
|
||||||
/// The workspace store's payload (M5).
|
/// The workspace store's payload (M5).
|
||||||
Json(serde_json::Value),
|
Json(serde_json::Value),
|
||||||
/// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the
|
/// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the
|
||||||
/// machine whose session was displaced, so the client that *did* the taking
|
/// 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.
|
/// but a takeover the new client cannot see is one the user cannot explain.
|
||||||
Attached {
|
Attached {
|
||||||
took_over_from: Option<String>,
|
took_over_from: Option<String>,
|
||||||
@@ -551,7 +604,7 @@ pub enum ControlEvent {
|
|||||||
pane_id: u64,
|
pane_id: u64,
|
||||||
json: serde_json::Value,
|
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.
|
/// session no longer holds it.
|
||||||
///
|
///
|
||||||
/// **`workspace` is not redundant.** One control connection carries a whole
|
/// **`workspace` is not redundant.** One control connection carries a whole
|
||||||
@@ -658,6 +711,23 @@ pub struct ControlHelloOk {
|
|||||||
/// Capability bits; see [`feature`].
|
/// Capability bits; see [`feature`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
|
/// 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 {
|
impl ControlHelloOk {
|
||||||
@@ -1019,7 +1089,7 @@ pub type EventSink = Box<dyn Fn(ControlEvent) + Send + Sync + 'static>;
|
|||||||
/// `shutdown`, a child process has `kill`, an SSH channel has `close` — hence
|
/// `shutdown`, a child process has `kill`, an SSH channel has `close` — hence
|
||||||
/// this one-method abstraction rather than a bound on the stream type.
|
/// 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.
|
/// exists there in mirror image, so `Duplex` will want the same capability.
|
||||||
/// Aligning is a matter of `Duplex` either requiring `LinkShutdown` as a
|
/// Aligning is a matter of `Duplex` either requiring `LinkShutdown` as a
|
||||||
/// supertrait or exposing an equivalent method; this trait is deliberately
|
/// 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
|
/// Issue a request and block until its reply, `deadline` elapses, or the
|
||||||
/// link drops.
|
/// 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
|
/// trait, and every caller is already on a background thread. Blocking here
|
||||||
/// blocks exactly one of them.
|
/// blocks exactly one of them.
|
||||||
pub fn call(&self, req: ControlRequest) -> io::Result<ReplyOk> {
|
pub fn call(&self, req: ControlRequest) -> io::Result<ReplyOk> {
|
||||||
@@ -1566,6 +1636,7 @@ mod tests {
|
|||||||
separator: '/',
|
separator: '/',
|
||||||
home: "/home/me".into(),
|
home: "/home/me".into(),
|
||||||
features: Vec::new(),
|
features: Vec::new(),
|
||||||
|
instance: "test-instance".into(),
|
||||||
},
|
},
|
||||||
shutdown: None,
|
shutdown: None,
|
||||||
reader_done: Mutex::new(false),
|
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 {
|
fn hello_ok() -> ControlHelloOk {
|
||||||
ControlHelloOk {
|
ControlHelloOk {
|
||||||
control_version: CONTROL_VERSION,
|
control_version: CONTROL_VERSION,
|
||||||
@@ -1872,6 +1965,7 @@ mod tests {
|
|||||||
separator: '/',
|
separator: '/',
|
||||||
home: "/home/me".into(),
|
home: "/home/me".into(),
|
||||||
features: vec![feature::CONTROL.into(), feature::HOST_RPC.into()],
|
features: vec![feature::CONTROL.into(), feature::HOST_RPC.into()],
|
||||||
|
instance: "test-instance".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
//! release tag → download URL, and the remote paths a server binary lives at.
|
//! 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
|
//! 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*
|
//! clock — which is the point: the asset naming here is a *literal* contract
|
||||||
//! contract with the release workflow, and a contract is only worth having if
|
//! with the release workflow (`.github/workflows/release.yml`), and a contract
|
||||||
//! both sides can be tested without standing up the other one.
|
//! 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
|
|
||||||
|
|
||||||
use std::fmt;
|
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
|
/// 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
|
/// (`{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";
|
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
|
/// 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
|
/// 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
|
/// 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`
|
/// the *client's* OS — a Windows client must not produce `.local\share`.
|
||||||
/// (contract §4.3).
|
|
||||||
pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"];
|
pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"];
|
||||||
|
|
||||||
/// Why a machine cannot be served a `tty7-server`.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// `amd64` / `arm64` are accepted alongside the values Linux actually reports
|
||||||
/// because some container images and BSD-flavoured userlands normalise to them.
|
/// 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
|
/// 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
|
/// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows
|
||||||
/// client — contract §4.3).
|
/// client).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RemotePaths {
|
pub struct RemotePaths {
|
||||||
/// `$HOME/.local/share/tty7/bin`.
|
/// `$HOME/.local/share/tty7/bin`.
|
||||||
@@ -199,6 +197,26 @@ pub fn binary_name(version: &str) -> String {
|
|||||||
format!("tty7-server-{version}")
|
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.
|
/// 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
|
/// This is how the running daemon's build is identified without asking it: the
|
||||||
@@ -220,7 +238,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The contract's mapping table, row for row. This test *is* the client half
|
/// 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.
|
/// asset, this is where the two sides stop agreeing.
|
||||||
#[test]
|
#[test]
|
||||||
fn uname_maps_to_the_published_assets() {
|
fn uname_maps_to_the_published_assets() {
|
||||||
|
|||||||
@@ -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
|
//! The release publishes one GNU coreutils `sha256sum`-format manifest covering
|
||||||
//! every asset. HTTPS to github.com is the trust anchor — the manifest is not
|
//! 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];
|
pub type Digest = [u8; 32];
|
||||||
|
|
||||||
/// Why an asset failed verification. Every variant aborts the install; none of
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ChecksumError {
|
pub enum ChecksumError {
|
||||||
/// The manifest has no line for this asset. Either the release is
|
/// 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.
|
/// other length or a non-hex character is a parse failure.
|
||||||
fn parse_hex(s: &str) -> Option<Digest> {
|
fn parse_hex(s: &str) -> Option<Digest> {
|
||||||
if s.len() != 64 {
|
if s.len() != 64 {
|
||||||
@@ -95,7 +95,7 @@ fn parse_hex(s: &str) -> Option<Digest> {
|
|||||||
|
|
||||||
/// The digest `manifest` records for `asset`.
|
/// 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
|
/// `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,
|
/// 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
|
/// 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");
|
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]
|
#[test]
|
||||||
fn digest_comparison_is_case_insensitive() {
|
fn digest_comparison_is_case_insensitive() {
|
||||||
let bytes = b"payload".as_slice();
|
let bytes = b"payload".as_slice();
|
||||||
@@ -174,7 +174,7 @@ mod tests {
|
|||||||
verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter");
|
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.
|
/// both digests reported — not retry, not install anyway.
|
||||||
#[test]
|
#[test]
|
||||||
fn mismatched_bytes_abort_with_both_digests() {
|
fn mismatched_bytes_abort_with_both_digests() {
|
||||||
@@ -255,8 +255,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **Whole-field match, not substring.** A manifest carrying a longer name
|
/// **Whole-field match, not substring.** A manifest carrying a longer name
|
||||||
/// that *contains* ours must not satisfy the lookup — this is the guard §16
|
/// that *contains* ours must not satisfy the lookup — this is the guard
|
||||||
/// step 2 asks for.
|
/// whole-field matching exists for.
|
||||||
#[test]
|
#[test]
|
||||||
fn filename_matching_is_exact_not_substring() {
|
fn filename_matching_is_exact_not_substring() {
|
||||||
let payload = b"decoy".as_slice();
|
let payload = b"decoy".as_slice();
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
|||||||
/// buffering it in memory before finding out is not a good trade.
|
/// buffering it in memory before finding out is not a good trade.
|
||||||
const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024;
|
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.
|
/// Downloads release assets over HTTPS.
|
||||||
pub struct HttpsFetcher {
|
pub struct HttpsFetcher {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
@@ -52,6 +58,14 @@ impl Default for HttpsFetcher {
|
|||||||
|
|
||||||
impl AssetFetcher for HttpsFetcher {
|
impl AssetFetcher for HttpsFetcher {
|
||||||
fn get(&self, url: &str) -> Result<Vec<u8>, String> {
|
fn get(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
|
self.get_with_progress(url, &|_, _| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_with_progress(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
on_progress: &dyn Fn(u64, Option<u64>),
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
let response = self
|
let response = self
|
||||||
.agent
|
.agent
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -73,16 +87,36 @@ impl AssetFetcher for HttpsFetcher {
|
|||||||
return Err(format!("{url} returned HTTP {status}"));
|
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::<u64>().ok())
|
||||||
|
.filter(|n| *n <= MAX_ASSET_BYTES);
|
||||||
|
|
||||||
let mut body = response.into_body();
|
let mut body = response.into_body();
|
||||||
let mut bytes = Vec::new();
|
// `take` still caps the read, so a lying (or absent) Content-Length
|
||||||
body.as_reader()
|
// cannot make this buffer more than the ceiling — one byte over is
|
||||||
.take(MAX_ASSET_BYTES + 1)
|
// enough to detect it, which is why the limit is `+ 1`.
|
||||||
.read_to_end(&mut bytes)
|
let mut reader = body.as_reader().take(MAX_ASSET_BYTES + 1);
|
||||||
.map_err(|e| describe(url, &e.to_string()))?;
|
let mut bytes = Vec::with_capacity(declared.unwrap_or(0) as usize);
|
||||||
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
let mut buf = vec![0u8; READ_CHUNK];
|
||||||
return Err(format!(
|
loop {
|
||||||
"{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset"
|
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)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
@@ -130,7 +164,7 @@ mod tests {
|
|||||||
/// of nothing. Asserting real content is what catches that.
|
/// of nothing. Asserting real content is what catches that.
|
||||||
/// - **The TLS trust anchor works.** ureq's webpki roots must accept
|
/// - **The TLS trust anchor works.** ureq's webpki roots must accept
|
||||||
/// github.com's chain; that HTTPS connection *is* the security model here
|
/// 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
|
/// Deliberately a small file rather than a release asset: assets are ~20 MB
|
||||||
/// and this is a correctness check, not a bandwidth test.
|
/// and this is a correctness check, not a bandwidth test.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Installing, launching and version-matching `tty7-server` on a remote machine
|
//! Installing, launching and version-matching `tty7-server` on a remote machine.
|
||||||
//! (design §12, §16, §17).
|
|
||||||
//!
|
//!
|
||||||
//! The six steps, in order:
|
//! The six steps, in order:
|
||||||
//!
|
//!
|
||||||
@@ -23,7 +22,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## …except for WSL, which downloads nothing
|
//! ## …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
|
//! *Windows client already shipped with*, not one fetched from a release. Both
|
||||||
//! paths meet at [`ServerBinarySource`] — [`ReleaseDownload`] for a real remote,
|
//! paths meet at [`ServerBinarySource`] — [`ReleaseDownload`] for a real remote,
|
||||||
//! [`wsl::BundledServerBinary`] for a distro on this machine — so steps 2 and
|
//! [`wsl::BundledServerBinary`] for a distro on this machine — so steps 2 and
|
||||||
@@ -40,7 +39,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Scope
|
//! ## 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
|
//! the workspace link either: this module's contract with the transport
|
||||||
//! (`remote_link` / the SSH router) is exactly [`ensure_remote_server`] — call it,
|
//! (`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.
|
//! 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
|
/// 0700 — the *directory* is 0700, which is what actually scopes access, and a
|
||||||
/// 0755 binary matches what every other user-local install looks like.
|
/// 0755 binary matches what every other user-local install looks like.
|
||||||
const BINARY_MODE: u32 = 0o755;
|
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;
|
const DIR_MODE: u32 = 0o700;
|
||||||
|
|
||||||
/// How long a freshly launched remote daemon gets to start answering on its
|
/// 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>;
|
fn chmod(&self, path: &str, mode: u32) -> Result<(), String>;
|
||||||
/// Write `bytes` to `path`, truncating anything already there.
|
/// Write `bytes` to `path`, truncating anything already there.
|
||||||
fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String>;
|
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.
|
/// Rename `from` over `to`. Same directory, so same filesystem, so atomic.
|
||||||
fn rename(&self, from: &str, to: &str) -> Result<(), String>;
|
fn rename(&self, from: &str, to: &str) -> Result<(), String>;
|
||||||
fn remove_file(&self, path: &str) -> Result<(), String>;
|
fn remove_file(&self, path: &str) -> Result<(), String>;
|
||||||
@@ -167,6 +182,22 @@ pub trait RemoteOps: Send + Sync {
|
|||||||
/// feature).
|
/// feature).
|
||||||
pub trait AssetFetcher: Send + Sync {
|
pub trait AssetFetcher: Send + Sync {
|
||||||
fn get(&self, url: &str) -> Result<Vec<u8>, String>;
|
fn get(&self, url: &str) -> Result<Vec<u8>, 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<u64>),
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let _ = on_progress;
|
||||||
|
self.get(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A verified server binary, and where it came from.
|
/// 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.
|
/// ourselves would verify nothing that the client's own signature did not.
|
||||||
pub trait ServerBinarySource: Send + Sync {
|
pub trait ServerBinarySource: Send + Sync {
|
||||||
fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError>;
|
fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError>;
|
||||||
|
|
||||||
|
/// [`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<u64>),
|
||||||
|
) -> Result<LoadedBinary, InstallError> {
|
||||||
|
let _ = on_progress;
|
||||||
|
self.load(version, asset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A local binary if [`wsl::BUNDLED_DIR_ENV`] names a directory holding one,
|
/// 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<'_> {
|
impl ServerBinarySource for BundledOrRelease<'_> {
|
||||||
fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError> {
|
fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError> {
|
||||||
|
self.load_with_progress(version, asset, &|_, _| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_with_progress(
|
||||||
|
&self,
|
||||||
|
version: &str,
|
||||||
|
asset: &'static str,
|
||||||
|
on_progress: &dyn Fn(u64, Option<u64>),
|
||||||
|
) -> Result<LoadedBinary, InstallError> {
|
||||||
match &self.bundled {
|
match &self.bundled {
|
||||||
// A named directory that does *not* hold this asset is an error, not
|
// 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
|
// a reason to fall back: someone who set the variable meant to
|
||||||
// install from it, and quietly downloading instead would defeat
|
// install from it, and quietly downloading instead would defeat
|
||||||
// whichever of the reasons above they set it for.
|
// whichever of the reasons above they set it for.
|
||||||
Some(bundled) => bundled.load(version, asset),
|
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
|
/// 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
|
/// 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 struct ReleaseDownload<'a> {
|
||||||
pub fetch: &'a dyn AssetFetcher,
|
pub fetch: &'a dyn AssetFetcher,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerBinarySource for ReleaseDownload<'_> {
|
impl ServerBinarySource for ReleaseDownload<'_> {
|
||||||
fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError> {
|
fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError> {
|
||||||
|
self.load_with_progress(version, asset, &|_, _| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_with_progress(
|
||||||
|
&self,
|
||||||
|
version: &str,
|
||||||
|
asset: &'static str,
|
||||||
|
on_progress: &dyn Fn(u64, Option<u64>),
|
||||||
|
) -> Result<LoadedBinary, InstallError> {
|
||||||
let tag = asset::release_tag(version);
|
let tag = asset::release_tag(version);
|
||||||
let manifest_url = asset::download_url(&tag, asset::CHECKSUMS_ASSET);
|
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
|
let manifest = self
|
||||||
.fetch
|
.fetch
|
||||||
.get(&manifest_url)
|
.get(&manifest_url)
|
||||||
@@ -280,7 +351,7 @@ impl ServerBinarySource for ReleaseDownload<'_> {
|
|||||||
let asset_url = asset::download_url(&tag, asset);
|
let asset_url = asset::download_url(&tag, asset);
|
||||||
let bytes = self
|
let bytes = self
|
||||||
.fetch
|
.fetch
|
||||||
.get(&asset_url)
|
.get_with_progress(&asset_url, on_progress)
|
||||||
.map_err(|reason| InstallError::Download {
|
.map_err(|reason| InstallError::Download {
|
||||||
url: asset_url.clone(),
|
url: asset_url.clone(),
|
||||||
reason,
|
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
|
/// 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
|
/// 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
|
/// **Only the first install on a given machine asks.** "First" is decided from
|
||||||
/// evidence on the remote itself — an empty (or absent)
|
/// evidence on the remote itself — an empty (or absent)
|
||||||
@@ -418,7 +489,219 @@ pub fn install_confirm() -> Arc<dyn InstallConfirm> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 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<u64> },
|
||||||
|
/// 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<f32> {
|
||||||
|
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<Mutex<Arc<dyn InstallProgress>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn progress_slot() -> &'static Mutex<Arc<dyn InstallProgress>> {
|
||||||
|
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<dyn InstallProgress>) {
|
||||||
|
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<Option<Arc<dyn InstallProgress>>> =
|
||||||
|
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<T>(progress: Arc<dyn InstallProgress>, 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<dyn InstallProgress> {
|
||||||
|
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<RemoteProtocol> {
|
||||||
|
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
|
/// 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 relay's landing point (`daemon::router`): the daemon finds the mismatch,
|
||||||
/// the GUI is the process with the keep-or-restart prompt, and
|
/// the GUI is the process with the keep-or-restart prompt, and
|
||||||
/// [`take_mismatched_remote_daemons`] only ever reads a local static. Without
|
/// [`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<MismatchedRemoteDaemon>) {
|
pub fn record_remote_mismatches(entries: Vec<MismatchedRemoteDaemon>) {
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
record_mismatch(entry);
|
record_mismatch(entry);
|
||||||
@@ -516,7 +799,7 @@ pub fn take_mismatched_remote_daemons() -> Vec<MismatchedRemoteDaemon> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Errors (§17: specific, path-bearing, never retried into a different path).
|
// Errors (specific, path-bearing, never retried into a different path).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -531,12 +814,11 @@ pub enum InstallError {
|
|||||||
/// proxy). Carries the URL, because "which release did it even look for" is
|
/// proxy). Carries the URL, because "which release did it even look for" is
|
||||||
/// the first question.
|
/// the first question.
|
||||||
Download { url: String, reason: String },
|
Download { url: String, reason: String },
|
||||||
/// sha256 verification failed. Terminal: no retry, no unverified fallback
|
/// sha256 verification failed. Terminal: no retry, no unverified fallback.
|
||||||
/// (§16, §17).
|
|
||||||
Checksum(ChecksumError),
|
Checksum(ChecksumError),
|
||||||
/// A WSL install found no bundled Linux server binary in this client's own
|
/// A WSL install found no bundled Linux server binary in this client's own
|
||||||
/// installation. Terminal, and deliberately **not** downgraded to a
|
/// 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
|
/// shipped with, and silently reaching for GitHub instead would turn a
|
||||||
/// packaging bug into an intermittent network failure on someone else's
|
/// packaging bug into an intermittent network failure on someone else's
|
||||||
/// machine. Names every directory that was looked in, because the fix is
|
/// machine. Names every directory that was looked in, because the fix is
|
||||||
@@ -549,7 +831,7 @@ pub enum InstallError {
|
|||||||
Declined { host: String, path: String },
|
Declined { host: String, path: String },
|
||||||
/// A write to the remote failed — full disk, read-only home, no permission.
|
/// 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**
|
/// 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 },
|
Write { path: String, reason: String },
|
||||||
/// The daemon would not start, or would not answer after starting.
|
/// The daemon would not start, or would not answer after starting.
|
||||||
Launch { reason: String },
|
Launch { reason: String },
|
||||||
@@ -623,8 +905,19 @@ pub struct InstallReport {
|
|||||||
pub confirmed: bool,
|
pub confirmed: bool,
|
||||||
/// Whether a daemon had to be launched (false when one was already serving).
|
/// Whether a daemon had to be launched (false when one was already serving).
|
||||||
pub launched: bool,
|
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<MismatchedRemoteDaemon>,
|
pub mismatch: Option<MismatchedRemoteDaemon>,
|
||||||
|
/// 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<RemoteProtocol>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -739,6 +1032,7 @@ impl<'a> Installer<'a> {
|
|||||||
confirmed: false,
|
confirmed: false,
|
||||||
launched: false,
|
launched: false,
|
||||||
mismatch: None,
|
mismatch: None,
|
||||||
|
reused: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A file that exists but is not executable is a half-finished install
|
// 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.
|
// than launching something the kernel will refuse.
|
||||||
let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0);
|
let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0);
|
||||||
if !usable {
|
if !usable {
|
||||||
let (confirmed, _) = self.install(asset, &paths)?;
|
// --- 3. before writing 8 MB, ask what is already serving ----------
|
||||||
report.installed = true;
|
//
|
||||||
report.confirmed = confirmed;
|
// 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 --------------------------------
|
// --- 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.launched = launched;
|
||||||
report.mismatch = mismatch;
|
report.mismatch = mismatch;
|
||||||
Ok(report)
|
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<Option<(String, RemoteProtocol)>, 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<RemoteProtocol> {
|
||||||
|
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<String> {
|
||||||
|
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.
|
/// Steps 3–5: download, verify, confirm, upload, publish.
|
||||||
fn install(
|
fn install(
|
||||||
&self,
|
&self,
|
||||||
@@ -808,8 +1168,12 @@ impl<'a> Installer<'a> {
|
|||||||
// refuses SETSTAT) must not block an install that will otherwise work.
|
// refuses SETSTAT) must not block an install that will otherwise work.
|
||||||
let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE);
|
let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE);
|
||||||
|
|
||||||
|
let sink = install_progress();
|
||||||
|
let total = bytes.len() as u64;
|
||||||
self.ops
|
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 {
|
.map_err(|reason| InstallError::Write {
|
||||||
path: paths.temp.clone(),
|
path: paths.temp.clone(),
|
||||||
reason,
|
reason,
|
||||||
@@ -847,8 +1211,12 @@ impl<'a> Installer<'a> {
|
|||||||
/// Where step 3's bytes come from: the injected source if there is one,
|
/// Where step 3's bytes come from: the injected source if there is one,
|
||||||
/// otherwise a [`ReleaseDownload`] over the injected fetcher.
|
/// otherwise a [`ReleaseDownload`] over the injected fetcher.
|
||||||
fn load_binary(&self, asset: &'static str) -> Result<LoadedBinary, InstallError> {
|
fn load_binary(&self, asset: &'static str) -> Result<LoadedBinary, InstallError> {
|
||||||
|
let sink = install_progress();
|
||||||
|
let on_progress = |done: u64, total: Option<u64>| {
|
||||||
|
sink.report(&self.host, InstallPhase::Downloading { done, total });
|
||||||
|
};
|
||||||
if let Some(source) = self.source {
|
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 {
|
let Some(fetch) = self.fetch else {
|
||||||
// Unreachable through either constructor; a plain error rather than
|
// 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(),
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// living down here is exactly the coupling that design forbids.
|
||||||
fn ensure_daemon(
|
fn ensure_daemon(
|
||||||
&self,
|
&self,
|
||||||
@@ -930,24 +1298,41 @@ impl<'a> Installer<'a> {
|
|||||||
.map_err(|reason| InstallError::Launch { reason })
|
.map_err(|reason| InstallError::Launch { reason })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identify the build of the daemon that is actually serving, and record a
|
/// Identify the daemon that is actually serving, and record a mismatch only
|
||||||
/// mismatch if it is not ours.
|
/// if it **cannot speak to us**.
|
||||||
///
|
///
|
||||||
/// The install path carries the version by construction, so reading the
|
/// **A different build is not a mismatch.** The rule is to compare
|
||||||
/// running process's executable link answers this for *every* build we have
|
/// `PROTOCOL_VERSION` and keep an older server that is compatible — the same judgement
|
||||||
/// shipped — including ones older than any handshake we could send them.
|
/// `spawn::ensure_running` makes locally, where a daemon whose `build`
|
||||||
/// Failing to read it is not an error: an unreadable `/proc` means we simply
|
/// differs but whose `protocol` matches is reused in silence. Comparing
|
||||||
/// have no opinion, and no opinion must never be reported as a mismatch.
|
/// 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<MismatchedRemoteDaemon> {
|
fn check_running_build(&self, paths: &RemotePaths) -> Option<MismatchedRemoteDaemon> {
|
||||||
let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?;
|
let exe = self.running_server_exe()?;
|
||||||
let exe = out.stdout.trim();
|
let exe = exe.as_str();
|
||||||
if exe.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let running_version = asset::version_from_path(exe);
|
let running_version = asset::version_from_path(exe);
|
||||||
if running_version.as_deref() == Some(self.version.as_str()) || exe == paths.binary {
|
if running_version.as_deref() == Some(self.version.as_str()) || exe == paths.binary {
|
||||||
return None;
|
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 {
|
let entry = MismatchedRemoteDaemon {
|
||||||
host: self.host.clone(),
|
host: self.host.clone(),
|
||||||
running_version,
|
running_version,
|
||||||
@@ -1007,7 +1392,7 @@ impl<'a> Installer<'a> {
|
|||||||
/// Find the executable path of this user's running `tty7-server`, if any.
|
/// Find the executable path of this user's running `tty7-server`, if any.
|
||||||
///
|
///
|
||||||
/// `readlink /proc/<pid>/exe` is readable only for the caller's own processes,
|
/// `readlink /proc/<pid>/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
|
/// `|| true` on the loop keeps a `set -e` login shell from turning "no daemon
|
||||||
/// running" into a failed command.
|
/// 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"#;
|
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"#;
|
||||||
|
|||||||
@@ -155,7 +155,16 @@ impl RemoteOps for SshRemoteOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
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> {
|
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
|
/// 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".
|
/// "the file isn't there, go ahead and install".
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The install flow, driven end to end against an in-memory remote.
|
//! 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
|
//! construction, atomic replacement, and the sha256 failure path — and none of
|
||||||
//! them may touch the network. The first two are unit-tested in
|
//! them may touch the network. The first two are unit-tested in
|
||||||
//! [`super::asset`] and [`super::checksums`]; the last two need the *whole*
|
//! [`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
|
/// Whether launching actually starts the fake daemon (false models a binary
|
||||||
/// that dies on exec).
|
/// that dies on exec).
|
||||||
launch_works: bool,
|
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<HashMap<String, RemoteProtocol>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeRemote {
|
impl FakeRemote {
|
||||||
@@ -85,9 +89,16 @@ impl FakeRemote {
|
|||||||
daemon_running: Mutex::new(false),
|
daemon_running: Mutex::new(false),
|
||||||
running_exe: Mutex::new(None),
|
running_exe: Mutex::new(None),
|
||||||
launch_works: true,
|
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).
|
/// A machine tty7 has installed on before (so consent is not re-asked).
|
||||||
fn with_previous_install(self, version: &str) -> Self {
|
fn with_previous_install(self, version: &str) -> Self {
|
||||||
self.preinstall(&format!("{BIN_DIR}/tty7-server-{version}"), 0o755);
|
self.preinstall(&format!("{BIN_DIR}/tty7-server-{version}"), 0o755);
|
||||||
@@ -152,6 +163,19 @@ impl RemoteOps for FakeRemote {
|
|||||||
if cmd == "uname -sm" {
|
if cmd == "uname -sm" {
|
||||||
return ok(&self.uname);
|
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 {
|
if cmd == RUNNING_EXE_COMMAND {
|
||||||
let exe = self.running_exe.lock().unwrap().clone().unwrap_or_default();
|
let exe = self.running_exe.lock().unwrap().clone().unwrap_or_default();
|
||||||
return ok(&exe);
|
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 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]
|
#[test]
|
||||||
fn the_install_directory_is_created_in_order_and_locked_down() {
|
fn the_install_directory_is_created_in_order_and_locked_down() {
|
||||||
let remote = FakeRemote::new();
|
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
|
/// **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.
|
/// and where the bytes came from.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_confirmation_states_path_size_and_origin() {
|
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
|
/// 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
|
/// **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".
|
/// /tmp".
|
||||||
#[test]
|
#[test]
|
||||||
fn a_failed_write_names_the_path_and_does_not_fall_back() {
|
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"
|
"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<Vec<(String, InstallPhase)>>);
|
||||||
|
|
||||||
|
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<InstallPhase> {
|
||||||
|
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<Vec<u8>, String> {
|
||||||
|
self.inner.get(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_with_progress(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
on_progress: &dyn Fn(u64, Option<u64>),
|
||||||
|
) -> Result<Vec<u8>, 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<u64>)> = 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<u64> = 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<String> = 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<String> = 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! WSL — a distribution on *this* machine as a remote workspace host
|
//! 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"
|
//! ## Why WSL is its own transport instead of "just another SSH host"
|
||||||
//!
|
//!
|
||||||
@@ -615,7 +615,7 @@ impl RemoteOps for WslRemoteOps {
|
|||||||
///
|
///
|
||||||
/// **Through `wsl.exe -- tee <path>`, not through a `\\wsl$\<distro>\…` UNC
|
/// **Through `wsl.exe -- tee <path>`, not through a `\\wsl$\<distro>\…` UNC
|
||||||
/// path**, and this is the one design decision in this file that had a real
|
/// 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 |
|
/// | | `\\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
|
/// 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.
|
/// 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
|
/// 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
|
/// from the same workspace version as the client asking for it; there is no tag
|
||||||
/// to resolve and no manifest to verify against.
|
/// 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
|
/// transport is specified to produce, and it has never been run — so the
|
||||||
/// string is pinned here instead.
|
/// string is pinned here instead.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1033,7 +1034,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The label a WSL host is prompted about, recorded under, and keyed by has
|
/// 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
|
/// compared as strings in the mismatch registry and in log lines a user is
|
||||||
/// meant to correlate.
|
/// meant to correlate.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1485,7 +1486,7 @@ mod tests {
|
|||||||
|
|
||||||
/// The bundled source loads bytes and reports where they came from, and its
|
/// 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
|
/// 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]
|
#[test]
|
||||||
fn a_missing_bundled_binary_names_every_place_it_looked() {
|
fn a_missing_bundled_binary_names_every_place_it_looked() {
|
||||||
let tmp = std::env::temp_dir().join(format!("tty7-wsl-src-{}", std::process::id()));
|
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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Declining writes nothing. Same rule as SSH — §12's "往别人机器上写二进制
|
/// Declining writes nothing. Same rule as SSH — writing a binary onto a
|
||||||
/// 值得问一次" applies to a distribution too, because it is still a
|
/// machine is worth asking about once, and a distribution is still a
|
||||||
/// filesystem the user owns and did not ask us to touch.
|
/// filesystem the user owns and did not ask us to touch.
|
||||||
#[test]
|
#[test]
|
||||||
fn declining_writes_nothing_into_the_distribution() {
|
fn declining_writes_nothing_into_the_distribution() {
|
||||||
|
|||||||
@@ -748,6 +748,13 @@ struct NativeSshBackend {
|
|||||||
/// locks.
|
/// locks.
|
||||||
pub struct DaemonPane {
|
pub struct DaemonPane {
|
||||||
pub id: u64,
|
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<String>,
|
||||||
/// The byte source (local PTY or native-SSH channel).
|
/// The byte source (local PTY or native-SSH channel).
|
||||||
backend: PaneBackend,
|
backend: PaneBackend,
|
||||||
/// The input side (keyboard input / pasted text): the PTY writer, or the
|
/// The input side (keyboard input / pasted text): the PTY writer, or the
|
||||||
@@ -839,6 +846,7 @@ impl DaemonPane {
|
|||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
size: WinSize,
|
size: WinSize,
|
||||||
shell: Option<ShellSpec>,
|
shell: Option<ShellSpec>,
|
||||||
|
owner: Option<String>,
|
||||||
on_dead: impl FnOnce() + Send + 'static,
|
on_dead: impl FnOnce() + Send + 'static,
|
||||||
) -> anyhow::Result<Arc<Self>> {
|
) -> anyhow::Result<Arc<Self>> {
|
||||||
let pty_size = pty_size(size);
|
let pty_size = pty_size(size);
|
||||||
@@ -880,6 +888,7 @@ impl DaemonPane {
|
|||||||
|
|
||||||
let pane = Arc::new(Self {
|
let pane = Arc::new(Self {
|
||||||
id,
|
id,
|
||||||
|
owner,
|
||||||
backend: PaneBackend::Pty(PtyBackend {
|
backend: PaneBackend::Pty(PtyBackend {
|
||||||
master: master.clone(),
|
master: master.clone(),
|
||||||
child: Mutex::new(child),
|
child: Mutex::new(child),
|
||||||
@@ -1002,6 +1011,10 @@ impl DaemonPane {
|
|||||||
|
|
||||||
let pane = Arc::new(Self {
|
let pane = Arc::new(Self {
|
||||||
id,
|
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 {
|
backend: PaneBackend::NativeSsh(NativeSshBackend {
|
||||||
handle: bridge.handle,
|
handle: bridge.handle,
|
||||||
connection: connection.clone(),
|
connection: connection.clone(),
|
||||||
@@ -1391,6 +1404,7 @@ impl DaemonPane {
|
|||||||
cwd: cwd.or_else(|| self.foreground_cwd()),
|
cwd: cwd.or_else(|| self.foreground_cwd()),
|
||||||
title: self.foreground_title(),
|
title: self.foreground_title(),
|
||||||
alive,
|
alive,
|
||||||
|
owner: self.owner.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2674,6 +2688,7 @@ mod tests {
|
|||||||
args: vec!["-c".into(), "cd /usr && exec cat".into()],
|
args: vec!["-c".into(), "cd /usr && exec cat".into()],
|
||||||
args_are_tty7_defaults: false,
|
args_are_tty7_defaults: false,
|
||||||
}),
|
}),
|
||||||
|
None,
|
||||||
|| {},
|
|| {},
|
||||||
)
|
)
|
||||||
.expect("spawn pane");
|
.expect("spawn pane");
|
||||||
|
|||||||
@@ -68,9 +68,16 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
|
|||||||
/// - **v1** — the dialect at the time versioning landed.
|
/// - **v1** — the dialect at the time versioning landed.
|
||||||
pub const PROTOCOL_VERSION: u32 = 3;
|
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
|
/// Reply to `ClientMsg::Version`: the protocol dialect the daemon speaks, plus
|
||||||
/// its crate version for logs/diagnostics. Only `protocol` and `features` drive
|
/// its crate version for logs/diagnostics. Only `protocol`, `features` and
|
||||||
/// decisions.
|
/// `instance` drive decisions.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct DaemonVersion {
|
pub struct DaemonVersion {
|
||||||
pub protocol: u32,
|
pub protocol: u32,
|
||||||
@@ -86,6 +93,15 @@ pub struct DaemonVersion {
|
|||||||
/// every user whose daemon happens to predate it.
|
/// every user whose daemon happens to predate it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
|
/// 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 {
|
impl DaemonVersion {
|
||||||
@@ -101,7 +117,11 @@ impl DaemonVersion {
|
|||||||
// control dialect is served by `tty7-server`, which advertises
|
// control dialect is served by `tty7-server`, which advertises
|
||||||
// `control` / `host-rpc` itself; claiming them here would make the
|
// `control` / `host-rpc` itself; claiming them here would make the
|
||||||
// GUI open a control connection this process cannot answer.
|
// 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<String> = 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
|
/// Terminal geometry shared by spawn/attach/resize. Cell pixel size travels too
|
||||||
/// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`),
|
/// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`),
|
||||||
/// which some full-screen apps read.
|
/// 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
|
/// False once the child has exited but the pane lingers (so a client can
|
||||||
/// still read its final scrollback).
|
/// still read its final scrollback).
|
||||||
pub alive: bool,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pane whose filesystem is not the host's — either a remote session, or a
|
/// 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
|
// 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
|
// `tty7-server`, and the only thing this side owns is the `SshConnection` the
|
||||||
@@ -906,6 +941,12 @@ pub enum ClientMsg {
|
|||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
size: WinSize,
|
size: WinSize,
|
||||||
shell: Option<ShellSpec>,
|
shell: Option<ShellSpec>,
|
||||||
|
/// 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<String>,
|
||||||
},
|
},
|
||||||
/// Bind this connection to an existing pane and (re)size it. The daemon
|
/// Bind this connection to an existing pane and (re)size it. The daemon
|
||||||
/// replies with a `Snapshot` then live `Output`.
|
/// 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.
|
/// every pane, forever, to feed a view that's usually closed.
|
||||||
QueryProcs { pane_id: u64 },
|
QueryProcs { pane_id: u64 },
|
||||||
/// A control request scoped to a **remote workspace's** SSH connection
|
/// 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<WorkspaceRequest>),
|
OnWorkspace(Box<WorkspaceRequest>),
|
||||||
/// Ask which protocol version the daemon speaks (control connection); the
|
/// Ask which protocol version the daemon speaks (control connection); the
|
||||||
/// daemon replies `Version`. A daemon that predates versioning doesn't know
|
/// 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
|
// here because this module is private and the router must not become a
|
||||||
// reason to open it — but the number is spent either way.
|
// reason to open it — but the number is spent either way.
|
||||||
/// `OnWorkspace` — a control request on a remote workspace's SSH connection
|
/// `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
|
/// router's 51, and of the retired 13; the contract's control connection
|
||||||
/// reserves 60-63, which this stays below.
|
/// reserves 60-63, which this stays below.
|
||||||
pub const ON_WORKSPACE: u8 = 52;
|
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
|
// Daemon -> client
|
||||||
pub const SPAWNED: u8 = 1;
|
pub const SPAWNED: u8 = 1;
|
||||||
@@ -1216,6 +1266,25 @@ pub fn read_frame<R: Read>(r: &mut R) -> io::Result<(u8, Vec<u8>)> {
|
|||||||
Ok((kind[0], payload))
|
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<u8> {
|
||||||
|
(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
|
/// Extract one complete frame from the front of `buf`, if fully buffered — the
|
||||||
/// resumable counterpart of [`read_frame`] for callers that read the stream
|
/// resumable counterpart of [`read_frame`] for callers that read the stream
|
||||||
/// with timeouts (the client reader enforces the DEC 2026 synchronized-update
|
/// with timeouts (the client reader enforces the DEC 2026 synchronized-update
|
||||||
@@ -1254,6 +1323,19 @@ fn from_json<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> io::Result<T> {
|
|||||||
serde_json::from_slice(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
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<PathBuf>,
|
||||||
|
size: WinSize,
|
||||||
|
#[serde(default)]
|
||||||
|
shell: Option<ShellSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
owner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl ClientMsg {
|
impl ClientMsg {
|
||||||
/// Encode and write this message as one frame.
|
/// Encode and write this message as one frame.
|
||||||
pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||||
@@ -1265,12 +1347,31 @@ impl ClientMsg {
|
|||||||
cwd,
|
cwd,
|
||||||
size,
|
size,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
} => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?),
|
} => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?),
|
||||||
ClientMsg::Spawn {
|
ClientMsg::Spawn {
|
||||||
cwd,
|
cwd,
|
||||||
size,
|
size,
|
||||||
shell: shell @ Some(_),
|
shell: shell @ Some(_),
|
||||||
|
owner: None,
|
||||||
} => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?),
|
} => 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 } => {
|
ClientMsg::Attach { pane_id, size } => {
|
||||||
write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?)
|
write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?)
|
||||||
}
|
}
|
||||||
@@ -1340,11 +1441,31 @@ impl ClientMsg {
|
|||||||
cwd,
|
cwd,
|
||||||
size,
|
size,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kind::SPAWN_SHELL => {
|
kind::SPAWN_SHELL => {
|
||||||
let (cwd, size, shell) = from_json(&payload)?;
|
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 => {
|
kind::ATTACH => {
|
||||||
let (pane_id, size) = from_json(&payload)?;
|
let (pane_id, size) = from_json(&payload)?;
|
||||||
@@ -1575,6 +1696,7 @@ mod tests {
|
|||||||
cwd: Some(PathBuf::from("/work")),
|
cwd: Some(PathBuf::from("/work")),
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
},
|
},
|
||||||
ClientMsg::Resize(SIZE),
|
ClientMsg::Resize(SIZE),
|
||||||
ClientMsg::Input(vec![b'l', b's', b'\r']),
|
ClientMsg::Input(vec![b'l', b's', b'\r']),
|
||||||
@@ -1630,11 +1752,13 @@ mod tests {
|
|||||||
cwd: Some(PathBuf::from("/tmp/x")),
|
cwd: Some(PathBuf::from("/tmp/x")),
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
},
|
},
|
||||||
ClientMsg::Spawn {
|
ClientMsg::Spawn {
|
||||||
cwd: None,
|
cwd: None,
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
},
|
},
|
||||||
ClientMsg::Spawn {
|
ClientMsg::Spawn {
|
||||||
cwd: Some(PathBuf::from("/tmp/x")),
|
cwd: Some(PathBuf::from("/tmp/x")),
|
||||||
@@ -1644,6 +1768,13 @@ mod tests {
|
|||||||
args: vec!["--distribution".into(), "Ubuntu".into()],
|
args: vec!["--distribution".into(), "Ubuntu".into()],
|
||||||
args_are_tty7_defaults: true,
|
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 {
|
ClientMsg::Attach {
|
||||||
pane_id: 42,
|
pane_id: 42,
|
||||||
@@ -1771,12 +1902,22 @@ mod tests {
|
|||||||
},
|
},
|
||||||
DaemonMsg::Exited { code: Some(0) },
|
DaemonMsg::Exited { code: Some(0) },
|
||||||
DaemonMsg::Exited { code: None },
|
DaemonMsg::Exited { code: None },
|
||||||
DaemonMsg::PaneList(vec![PaneInfo {
|
DaemonMsg::PaneList(vec![
|
||||||
pane_id: 3,
|
PaneInfo {
|
||||||
cwd: Some(PathBuf::from("/x")),
|
pane_id: 3,
|
||||||
title: "zsh".into(),
|
cwd: Some(PathBuf::from("/x")),
|
||||||
alive: true,
|
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 {
|
DaemonMsg::RemoteContext(Some(RemoteContext {
|
||||||
kind: RemoteKind::Ssh,
|
kind: RemoteKind::Ssh,
|
||||||
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
|
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
|
||||||
@@ -1899,11 +2040,13 @@ mod tests {
|
|||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
build: "0.15.0".into(),
|
build: "0.15.0".into(),
|
||||||
features: vec!["control".into(), "host-rpc".into()],
|
features: vec!["control".into(), "host-rpc".into()],
|
||||||
|
instance: "inst-a".into(),
|
||||||
}),
|
}),
|
||||||
DaemonMsg::Version(DaemonVersion {
|
DaemonMsg::Version(DaemonVersion {
|
||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
build: "0.15.0".into(),
|
build: "0.15.0".into(),
|
||||||
features: Vec::new(),
|
features: Vec::new(),
|
||||||
|
instance: String::new(),
|
||||||
}),
|
}),
|
||||||
DaemonMsg::Error("nope".into()),
|
DaemonMsg::Error("nope".into()),
|
||||||
];
|
];
|
||||||
@@ -1929,6 +2072,7 @@ mod tests {
|
|||||||
cwd: Some(PathBuf::from("/work")),
|
cwd: Some(PathBuf::from("/work")),
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
};
|
};
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
msg.encode(&mut buf).unwrap();
|
msg.encode(&mut buf).unwrap();
|
||||||
@@ -1949,6 +2093,7 @@ mod tests {
|
|||||||
cwd: Some(PathBuf::from("/old")),
|
cwd: Some(PathBuf::from("/old")),
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: None,
|
shell: None,
|
||||||
|
owner: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1967,6 +2112,7 @@ mod tests {
|
|||||||
cwd: Some(PathBuf::from("/work")),
|
cwd: Some(PathBuf::from("/work")),
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: Some(shell.clone()),
|
shell: Some(shell.clone()),
|
||||||
|
owner: None,
|
||||||
};
|
};
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
msg.encode(&mut buf).unwrap();
|
msg.encode(&mut buf).unwrap();
|
||||||
@@ -1979,10 +2125,74 @@ mod tests {
|
|||||||
cwd: Some(PathBuf::from("/work")),
|
cwd: Some(PathBuf::from("/work")),
|
||||||
size: SIZE,
|
size: SIZE,
|
||||||
shell: Some(shell),
|
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 empty-payload binary frame (e.g. an `Input([])`) still round-trips and
|
||||||
/// an oversize length is rejected.
|
/// an oversize length is rejected.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -104,8 +104,7 @@ impl RemoteLink {
|
|||||||
Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?))
|
Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn `wsl.exe -d <distro> -- <server> --stdio` and take its stdio
|
/// Spawn `wsl.exe -d <distro> -- <server> --stdio` and take its stdio.
|
||||||
/// (design §7.3).
|
|
||||||
///
|
///
|
||||||
/// `server` is an **absolute path inside the distribution**, not a bare
|
/// `server` is an **absolute path inside the distribution**, not a bare
|
||||||
/// name: `wsl.exe` runs the command without a login shell, so the `PATH`
|
/// 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;
|
const MAX_SOCKET_PATH_BYTES: usize = 100;
|
||||||
|
|
||||||
/// How this connection reaches the remote `tty7-server` — decided once per SSH
|
/// 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
|
/// Probing per channel would put a failed `direct-streamlocal` open in front of
|
||||||
/// every pane on a host whose admin turned `AllowStreamLocalForwarding` off,
|
/// 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.
|
/// bridge that resolves the path in the process that binds it.
|
||||||
///
|
///
|
||||||
/// Paths are joined as POSIX strings, never `PathBuf`: on a Windows client
|
/// 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<String> {
|
pub fn remote_control_socket(env: &RemoteEnv) -> Option<String> {
|
||||||
if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) {
|
if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) {
|
||||||
return Some(explicit.to_string());
|
return Some(explicit.to_string());
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! [`RemoteRouter`] — the local daemon as a forwarding hub for remote
|
//! [`RemoteRouter`] — the local daemon as a forwarding hub for remote
|
||||||
//! workspaces (design §6).
|
//! workspaces.
|
||||||
//!
|
//!
|
||||||
//! ## The shape
|
//! ## The shape
|
||||||
//!
|
//!
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
//!
|
//!
|
||||||
//! A router that parsed the stream would become a third opinion about the
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! [`RouteAction::RestartServer`] uses the same window in the opposite
|
||||||
//! direction: the *client* tells the daemon to replace the `tty7-server` on the
|
//! 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
|
//! reason the prompts are — the decision needs a user and the act needs an
|
||||||
//! `Arc<SshConnection>`, and those live in different processes — and it fits the
|
//! `Arc<SshConnection>`, and those live in different processes — and it fits the
|
||||||
//! window's own rule, being a precondition for a usable link rather than
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::daemon::install::{
|
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::protocol::{self, AuthPromptKind, AuthResponse, DaemonMsg, NativeSshSpec};
|
||||||
use crate::daemon::remote_link::RemoteLink;
|
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,
|
/// A host reached over SSH: `direct-streamlocal` when the server allows it,
|
||||||
/// `tty7-server --stdio` on a session channel when it does not.
|
/// `tty7-server --stdio` on a session channel when it does not.
|
||||||
Ssh(Box<NativeSshSpec>),
|
Ssh(Box<NativeSshSpec>),
|
||||||
/// 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,
|
/// 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
|
/// 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) —
|
/// 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
|
/// 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<SshConnection>`, which exists only in the daemon process,
|
/// machine's `Arc<SshConnection>`, which exists only in the daemon process,
|
||||||
/// while the decision to do it can only be made by the process with a user in
|
/// 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
|
/// 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
|
/// and no link is opened: there is nothing to talk to afterwards, since the
|
||||||
/// daemon that was serving is the one being replaced.
|
/// 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".
|
/// a user has answered the keep-or-restart prompt with "Restart Server".
|
||||||
RestartServer,
|
RestartServer,
|
||||||
}
|
}
|
||||||
@@ -251,7 +252,7 @@ pub struct RouteHeader {
|
|||||||
/// What the daemon should do with this connection.
|
/// What the daemon should do with this connection.
|
||||||
///
|
///
|
||||||
/// `#[serde(default)]` = [`RouteAction::Forward`], the only thing a routed
|
/// `#[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.
|
/// process boundary.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub action: RouteAction,
|
pub action: RouteAction,
|
||||||
@@ -296,7 +297,7 @@ impl RouteHeader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The same machine, but asking the daemon to replace the `tty7-server`
|
/// 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
|
/// The connection carries nothing afterwards: the ack is the whole
|
||||||
/// conversation. Callers must have a user's explicit "Restart Server" behind
|
/// conversation. Callers must have a user's explicit "Restart Server" behind
|
||||||
@@ -452,7 +453,7 @@ pub enum RoutePrompt {
|
|||||||
request_id: u64,
|
request_id: u64,
|
||||||
prompt: AuthPromptKind,
|
prompt: AuthPromptKind,
|
||||||
},
|
},
|
||||||
/// "May tty7 write a server binary onto this machine?" (design §12).
|
/// "May tty7 write a server binary onto this machine?".
|
||||||
Install {
|
Install {
|
||||||
request_id: u64,
|
request_id: u64,
|
||||||
request: Box<InstallRequestWire>,
|
request: Box<InstallRequestWire>,
|
||||||
@@ -464,6 +465,12 @@ pub enum RoutePrompt {
|
|||||||
Mismatch {
|
Mismatch {
|
||||||
daemons: Vec<MismatchedRemoteDaemon>,
|
daemons: Vec<MismatchedRemoteDaemon>,
|
||||||
},
|
},
|
||||||
|
/// 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`].
|
/// The client's answer to a [`RoutePrompt`].
|
||||||
@@ -524,7 +531,7 @@ impl RouteReply {
|
|||||||
/// **`machine` is the connection's own target**, handed down from the
|
/// **`machine` is the connection's own target**, handed down from the
|
||||||
/// [`RouteHeader`] this negotiation opened with. A client with more than one
|
/// [`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
|
/// 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
|
/// 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
|
/// *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
|
/// pane's (`connect_routed` never set it), leaving every routed pane prompt
|
||||||
@@ -633,6 +640,13 @@ fn answer(machine: &RouteTarget, prompt: RoutePrompt) -> Option<RouteReply> {
|
|||||||
crate::daemon::install::record_remote_mismatches(daemons);
|
crate::daemon::install::record_remote_mismatches(daemons);
|
||||||
None
|
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`]
|
/// Install consent, in the shape [`crate::daemon::install::Installer`]
|
||||||
/// already speaks.
|
/// already speaks.
|
||||||
pub confirm: Arc<dyn InstallConfirm>,
|
pub confirm: Arc<dyn InstallConfirm>,
|
||||||
|
/// 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<dyn InstallProgress>,
|
||||||
/// Where a build mismatch discovered during setup is collected, to be
|
/// Where a build mismatch discovered during setup is collected, to be
|
||||||
/// handed to the client instead of to this process's registry.
|
/// handed to the client instead of to this process's registry.
|
||||||
pub mismatches: Arc<Mutex<Vec<MismatchedRemoteDaemon>>>,
|
pub mismatches: Arc<Mutex<Vec<MismatchedRemoteDaemon>>>,
|
||||||
@@ -745,6 +763,7 @@ impl RouteSetup {
|
|||||||
RouteSetup {
|
RouteSetup {
|
||||||
broker: PromptBroker::new(Box::new(|_| false)),
|
broker: PromptBroker::new(Box::new(|_| false)),
|
||||||
confirm: Arc::new(crate::daemon::install::DenyInstall),
|
confirm: Arc::new(crate::daemon::install::DenyInstall),
|
||||||
|
progress: Arc::new(crate::daemon::install::SilentProgress),
|
||||||
mismatches: Arc::new(Mutex::new(Vec::new())),
|
mismatches: Arc::new(Mutex::new(Vec::new())),
|
||||||
channel,
|
channel,
|
||||||
}
|
}
|
||||||
@@ -764,10 +783,13 @@ impl RouteSetup {
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let confirm = self.confirm.clone();
|
let confirm = self.confirm.clone();
|
||||||
|
let progress = self.progress.clone();
|
||||||
let sink = self.mismatches.clone();
|
let sink = self.mismatches.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
crate::daemon::install::with_install_confirm(confirm, || {
|
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
|
.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
|
/// The forwarding hub. Stateless: a routed connection's only state is the two
|
||||||
/// halves of the pipe, and both die with it.
|
/// halves of the pipe, and both die with it.
|
||||||
pub struct RemoteRouter;
|
pub struct RemoteRouter;
|
||||||
@@ -887,6 +929,7 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> {
|
|||||||
_ => true,
|
_ => true,
|
||||||
})),
|
})),
|
||||||
confirm: relay.clone(),
|
confirm: relay.clone(),
|
||||||
|
progress: relay.clone(),
|
||||||
mismatches: Arc::new(Mutex::new(Vec::new())),
|
mismatches: Arc::new(Mutex::new(Vec::new())),
|
||||||
channel: header.channel,
|
channel: header.channel,
|
||||||
};
|
};
|
||||||
@@ -939,7 +982,7 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> {
|
|||||||
// but dropping it would be a silent truncation.
|
// but dropping it would be a silent truncation.
|
||||||
Some((link, conn, frames.into_buffer()))
|
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
|
// 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 last thing this connection carries. The client reconnects to
|
||||||
// the new one on its own — the supervisor's reconnect is already the
|
// 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<Per
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Design §12's "restart the service", on the side of the boundary that holds
|
/// "Restart Server", on the side of the boundary that holds
|
||||||
/// the connection.
|
/// the connection.
|
||||||
///
|
///
|
||||||
/// SSH only, and that is not a gap being deferred: a WSL distribution's server
|
/// SSH only, and that is not a gap being deferred: a WSL distribution's server
|
||||||
@@ -1118,7 +1161,7 @@ async fn open_link(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok((link, Some(conn)))
|
Ok((link, Some(conn)))
|
||||||
}
|
}
|
||||||
// WSL (design §7.3): no connection object, so nothing is returned to
|
// WSL: no connection object, so nothing is returned to
|
||||||
// hold open — the link *is* the child process, and dropping it reaps it.
|
// hold open — the link *is* the child process, and dropping it reaps it.
|
||||||
//
|
//
|
||||||
// `ensure_wsl_server` runs first, on a blocking thread: it is several
|
// `ensure_wsl_server` runs first, on a blocking thread: it is several
|
||||||
@@ -1197,7 +1240,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A WSL header carries the distro name and nothing else — no user, no
|
/// A WSL header carries the distro name and nothing else — no user, no
|
||||||
/// port, no credentials, because none of those exist for a distribution on
|
/// port, no credentials, because none of those exist for a distribution on
|
||||||
/// this machine (design §7.3, D9).
|
/// this machine (D9).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_wsl_header_round_trips_with_only_a_distro_name() {
|
fn a_wsl_header_round_trips_with_only_a_distro_name() {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
@@ -1670,7 +1713,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// The restart action (design §12): the one thing on this connection that
|
// The restart action: the one thing on this connection that
|
||||||
// travels client → daemon.
|
// travels client → daemon.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,12 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let first = ClientMsg::from_frame(first_kind, first_payload)?;
|
let first = ClientMsg::from_frame(first_kind, first_payload)?;
|
||||||
match first {
|
match first {
|
||||||
ClientMsg::Spawn { cwd, size, shell } => {
|
ClientMsg::Spawn {
|
||||||
|
cwd,
|
||||||
|
size,
|
||||||
|
shell,
|
||||||
|
owner,
|
||||||
|
} => {
|
||||||
let id = registry.alloc_id();
|
let id = registry.alloc_id();
|
||||||
// Reclaim a pane whose child exits while *detached* (nobody attached,
|
// Reclaim a pane whose child exits while *detached* (nobody attached,
|
||||||
// so no connection's detach path will ever drop it): remove it from
|
// so no connection's detach path will ever drop it): remove it from
|
||||||
@@ -272,7 +277,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
|||||||
.ok();
|
.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,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Report the failure to the client and close.
|
// Report the failure to the client and close.
|
||||||
@@ -540,7 +545,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// A remote workspace has no pane here to address, so its forwards and
|
// A remote workspace has no pane here to address, so its forwards and
|
||||||
// SFTP go through one envelope that names the connection instead
|
// 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.
|
// `ssh::workspace::handle`, so this arm stays a pipe.
|
||||||
ClientMsg::OnWorkspace(req) => {
|
ClientMsg::OnWorkspace(req) => {
|
||||||
let mut w = write_stream;
|
let mut w = write_stream;
|
||||||
|
|||||||
@@ -66,6 +66,42 @@ pub fn take_mismatched_daemon() -> Option<MismatchedDaemon> {
|
|||||||
MISMATCHED_DAEMON.lock().ok()?.take()
|
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<Option<DaemonVersion>> = 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<String> {
|
||||||
|
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<DaemonVersion>) {
|
||||||
|
if let Ok(mut slot) = LOCAL_DAEMON.lock() {
|
||||||
|
*slot = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How a live daemon answered the version handshake.
|
/// How a live daemon answered the version handshake.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum VersionProbe {
|
enum VersionProbe {
|
||||||
@@ -96,7 +132,10 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
|||||||
// panes either way.
|
// panes either way.
|
||||||
if let Ok(mut stream) = transport::connect() {
|
if let Ok(mut stream) = transport::connect() {
|
||||||
match query_daemon_version(&mut stream) {
|
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) => {
|
VersionProbe::Speaks(v) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"daemon (build {}) speaks protocol {}, this build needs {}; \
|
"daemon (build {}) speaks protocol {}, this build needs {}; \
|
||||||
@@ -105,6 +144,9 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
|||||||
v.protocol,
|
v.protocol,
|
||||||
PROTOCOL_VERSION
|
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() {
|
if let Ok(mut slot) = MISMATCHED_DAEMON.lock() {
|
||||||
*slot = Some(MismatchedDaemon { version: Some(v) });
|
*slot = Some(MismatchedDaemon { version: Some(v) });
|
||||||
}
|
}
|
||||||
@@ -114,6 +156,7 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
|||||||
log::warn!(
|
log::warn!(
|
||||||
"daemon predates protocol versioning; keeping it and deferring to the user"
|
"daemon predates protocol versioning; keeping it and deferring to the user"
|
||||||
);
|
);
|
||||||
|
note_local_daemon(None);
|
||||||
if let Ok(mut slot) = MISMATCHED_DAEMON.lock() {
|
if let Ok(mut slot) = MISMATCHED_DAEMON.lock() {
|
||||||
*slot = Some(MismatchedDaemon { version: None });
|
*slot = Some(MismatchedDaemon { version: None });
|
||||||
}
|
}
|
||||||
@@ -121,6 +164,7 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
VersionProbe::Unresponsive => {
|
VersionProbe::Unresponsive => {
|
||||||
log::info!("daemon did not answer the version handshake; restarting it");
|
log::info!("daemon did not answer the version handshake; restarting it");
|
||||||
|
note_local_daemon(None);
|
||||||
drop(stream);
|
drop(stream);
|
||||||
// `stop` shuts the old daemon down gracefully (`Shutdown`
|
// `stop` shuts the old daemon down gracefully (`Shutdown`
|
||||||
// predates versioning, so even the oldest daemon honors it),
|
// 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.
|
// (via `bind`) slightly before the accept loop is ready.
|
||||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||||
loop {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
@@ -619,6 +670,7 @@ mod tests {
|
|||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
build: "test".into(),
|
build: "test".into(),
|
||||||
features: Vec::new(),
|
features: Vec::new(),
|
||||||
|
instance: "inst-test".into(),
|
||||||
})
|
})
|
||||||
.encode(&mut daemon)
|
.encode(&mut daemon)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! connection alive exactly like `ssh -N`.
|
//! connection alive exactly like `ssh -N`.
|
||||||
//!
|
//!
|
||||||
//! There are two owners, because tty7 has two unrelated features that both open
|
//! There are two owners, because tty7 has two unrelated features that both open
|
||||||
//! forwards (design §2):
|
//! forwards:
|
||||||
//!
|
//!
|
||||||
//! | | SSH pane ("连一下") | remote workspace ("在上面开发") |
|
//! | | SSH pane ("连一下") | remote workspace ("在上面开发") |
|
||||||
//! |---|---|---|
|
//! |---|---|---|
|
||||||
@@ -410,7 +410,7 @@ impl SshForwardRegistry {
|
|||||||
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
|
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
|
/// [`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
|
/// 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
|
/// [`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
|
/// The forward is owned by the workspace, so clicking the link in one pane
|
||||||
/// and then closing that pane leaves the browser tab working.
|
/// 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`
|
/// 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(
|
pub fn ensure_workspace_loopback(
|
||||||
&self,
|
&self,
|
||||||
workspace: WorkspaceId,
|
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
|
/// workspace are transient — a tab closed, a pane respawned after a reconnect
|
||||||
/// — so a forward the user ⌘-clicked into existence must outlive them. Only
|
/// — so a forward the user ⌘-clicked into existence must outlive them. Only
|
||||||
/// closing the *workspace* collects it.
|
/// closing the *workspace* collects it.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub mod forward;
|
|||||||
pub mod known_hosts;
|
pub mod known_hosts;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sftp;
|
pub mod sftp;
|
||||||
/// Workspace-scoped control requests (design §15) — see `workspace::handle`.
|
/// Workspace-scoped control requests — see `workspace::handle`.
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -393,7 +393,7 @@ impl SshManager {
|
|||||||
/// [`ConnectionKey`] registry the SSH panes use, so a workspace opened
|
/// [`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
|
/// 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
|
/// 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
|
/// per pane, one per workspace control stream; no multiplexing of our own on
|
||||||
/// top of SSH's.
|
/// top of SSH's.
|
||||||
///
|
///
|
||||||
@@ -499,7 +499,7 @@ impl SshManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the `tty7-server` running on `spec`'s host with this client's
|
/// 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**.
|
/// that server is hosting**.
|
||||||
///
|
///
|
||||||
/// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction)
|
/// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction)
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ pub struct SshConnection {
|
|||||||
remote_forwards: RemoteForwardTable,
|
remote_forwards: RemoteForwardTable,
|
||||||
alive: AtomicBool,
|
alive: AtomicBool,
|
||||||
/// How this host's `tty7-server` is reached — probed once, then reused by
|
/// 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`
|
/// Per *connection*, not per channel: deciding costs a round trip (an `exec`
|
||||||
/// to read the remote's environment, and on a host with
|
/// 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
|
/// 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
|
/// 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
|
/// far end sees an ordinary local connection and needs no extra process. The
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ use crate::daemon::protocol::{
|
|||||||
|
|
||||||
use super::{ConnectionKey, SshConnection, SshManager};
|
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;
|
const CHUNK: usize = 256 * 1024;
|
||||||
|
|
||||||
/// How long a finished job's final progress lingers for the GUI to observe before
|
/// 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
|
/// 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
|
/// swallowing that would leave a truncated file for the caller to chmod and
|
||||||
/// rename into place as though it were whole.
|
/// 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(
|
pub fn put_bytes(
|
||||||
&self,
|
&self,
|
||||||
conn: &Arc<SshConnection>,
|
conn: &Arc<SshConnection>,
|
||||||
path: &str,
|
path: &str,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
|
on_progress: &(dyn Fn(u64) + Send + Sync),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
SshManager::global().handle().block_on(async {
|
SshManager::global().handle().block_on(async {
|
||||||
self.with_session(conn, |sftp| async move {
|
self.with_session(conn, |sftp| async move {
|
||||||
@@ -401,8 +410,11 @@ impl SftpManager {
|
|||||||
.open_with_flags(path.to_string(), flags)
|
.open_with_flags(path.to_string(), flags)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e}"))?;
|
.map_err(|e| format!("{e}"))?;
|
||||||
|
let mut written = 0u64;
|
||||||
for chunk in bytes.chunks(CHUNK) {
|
for chunk in bytes.chunks(CHUNK) {
|
||||||
file.write_all(chunk).await.map_err(|e| format!("{e}"))?;
|
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.flush().await.map_err(|e| format!("{e}"))?;
|
||||||
file.shutdown().await.map_err(|e| format!("{e}"))?;
|
file.shutdown().await.map_err(|e| format!("{e}"))?;
|
||||||
|
|||||||
@@ -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
|
//! 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
|
//! 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
|
//! connection an SSH pane to that host would have used, deduplicated by
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ macro_rules! for_each_host_case {
|
|||||||
search_skips_ignored_dirs,
|
search_skips_ignored_dirs,
|
||||||
search_respects_limit,
|
search_respects_limit,
|
||||||
search_respects_max_dirs,
|
search_respects_max_dirs,
|
||||||
|
// machine inventory
|
||||||
|
shells_are_named_and_have_a_default,
|
||||||
// watch
|
// watch
|
||||||
watch_reports_create_and_delete,
|
watch_reports_create_and_delete,
|
||||||
watch_is_non_recursive,
|
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"]);
|
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
|
// watch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ use notify::{RecursiveMode, Watcher};
|
|||||||
use crate::core::git;
|
use crate::core::git;
|
||||||
use crate::core::gitignore::GitignoreChain;
|
use crate::core::gitignore::GitignoreChain;
|
||||||
use crate::host::{
|
use crate::host::{
|
||||||
Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, WatchHandle, WatchSub,
|
Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle,
|
||||||
guard_off_ui,
|
WatchSub, guard_off_ui,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// How long changes are collected before a batch is delivered. Matched exactly
|
/// 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)
|
git::git_output(cwd, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shells(&self) -> io::Result<ShellInventory> {
|
||||||
|
guard_off_ui();
|
||||||
|
Ok(crate::core::shells::inventory())
|
||||||
|
}
|
||||||
|
|
||||||
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub> {
|
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub> {
|
||||||
guard_off_ui();
|
guard_off_ui();
|
||||||
local_watch(dirs, Arc::clone(&self.gitignore))
|
local_watch(dirs, Arc::clone(&self.gitignore))
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
//!
|
//!
|
||||||
//! # Blocking on purpose
|
//! # Blocking on purpose
|
||||||
//!
|
//!
|
||||||
//! Every method blocks. That is a decision, not an oversight
|
//! Every method blocks. That is a decision, not an oversight: the trait
|
||||||
//! (`docs/2026-07-27-remote-workspace-impl-contract.md` §1): the trait has to be
|
//! has to be
|
||||||
//! object-safe because the whole tree holds `Arc<dyn Host>`, the server side
|
//! object-safe because the whole tree holds `Arc<dyn Host>`, the server side
|
||||||
//! serves these same calls from a blocking thread pool, and a GPUI
|
//! serves these same calls from a blocking thread pool, and a GPUI
|
||||||
//! `&mut Context<T>` cannot be held across an `.await` anyway — so making the
|
//! `&mut Context<T>` cannot be held across an `.await` anyway — so making the
|
||||||
@@ -46,6 +46,8 @@ use std::sync::Arc;
|
|||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::thread::ThreadId;
|
use std::thread::ThreadId;
|
||||||
|
|
||||||
|
pub use crate::core::shells::ShellInventory;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identity
|
// Identity
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -456,7 +458,7 @@ pub trait Host: Send + Sync + 'static {
|
|||||||
/// conflict prompt. The post-write metadata is the write's own answer, so
|
/// 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
|
/// 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
|
/// 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(|_| ())`.
|
/// Callers that genuinely don't want it write `.map(|_| ())`.
|
||||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta>;
|
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta>;
|
||||||
@@ -503,6 +505,18 @@ pub trait Host: Send + Sync + 'static {
|
|||||||
/// `GIT_WORK_TREE` cleared, and both output streams captured.
|
/// `GIT_WORK_TREE` cleared, and both output streams captured.
|
||||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
|
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
|
||||||
|
|
||||||
|
// ----- 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<ShellInventory>;
|
||||||
|
|
||||||
// ----- watching --------------------------------------------------------
|
// ----- watching --------------------------------------------------------
|
||||||
|
|
||||||
/// Open a long-lived, non-recursive watch over `dirs` (which may be empty —
|
/// Open a long-lived, non-recursive watch over `dirs` (which may be empty —
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ use crate::daemon::control::{
|
|||||||
ReplyOk,
|
ReplyOk,
|
||||||
};
|
};
|
||||||
use crate::host::{
|
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.
|
/// 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<ShellInventory> {
|
||||||
|
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<WatchSub> {
|
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub> {
|
||||||
let id = match self.call(ControlRequest::WatchOpen {
|
let id = match self.call(ControlRequest::WatchOpen {
|
||||||
dirs: wire_paths(dirs),
|
dirs: wire_paths(dirs),
|
||||||
@@ -593,6 +604,7 @@ mod tests {
|
|||||||
crate::daemon::control::feature::CONTROL.into(),
|
crate::daemon::control::feature::CONTROL.into(),
|
||||||
crate::daemon::control::feature::HOST_RPC.into(),
|
crate::daemon::control::feature::HOST_RPC.into(),
|
||||||
],
|
],
|
||||||
|
instance: "test-instance".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,6 +688,33 @@ mod tests {
|
|||||||
(host, seen_rx)
|
(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
|
/// Path arithmetic follows the *peer's* separator, not the client's. On a
|
||||||
/// Windows client this is the difference between `/home/me/src` and
|
/// Windows client this is the difference between `/home/me/src` and
|
||||||
/// `/home/me\src`, and between "absolute" and "drive-relative".
|
/// `/home/me\src`, and between "absolute" and "drive-relative".
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! carries nothing else.
|
//! carries nothing else.
|
||||||
//!
|
//!
|
||||||
//! That symmetry is not an accident, it is the reason the trait is blocking
|
//! 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
|
//! 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
|
//! a remote one — no async mirror of every method, and no second implementation
|
||||||
//! to drift.
|
//! 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
|
/// [`Attachment`](crate::core::workspace_store::Attachment) in the store is the
|
||||||
/// *data* — token, hostname, since — and answers "who holds this workspace".
|
/// *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
|
/// Whether the link exists *for* this workspace, and so should be closed
|
||||||
/// with it.
|
/// 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
|
/// 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
|
/// exactly right, and it is the strong form of the guarantee: the old client
|
||||||
/// cannot write again even if it is wedged or hostile.
|
/// 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
|
/// 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
|
/// 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
|
/// 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: Read>(
|
fn handshake<R: Read>(
|
||||||
r: &mut R,
|
r: &mut R,
|
||||||
sink: &Sink,
|
sink: &Sink,
|
||||||
@@ -513,6 +513,7 @@ fn handshake<R: Read>(
|
|||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
features,
|
features,
|
||||||
|
instance: crate::daemon::control::server_instance().to_string(),
|
||||||
}))?;
|
}))?;
|
||||||
|
|
||||||
if hello.control_version != CONTROL_VERSION {
|
if hello.control_version != CONTROL_VERSION {
|
||||||
@@ -529,7 +530,7 @@ fn handshake<R: Read>(
|
|||||||
/// connections apart even when the same client opens both.
|
/// connections apart even when the same client opens both.
|
||||||
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
|
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.
|
/// tell whoever held it.
|
||||||
///
|
///
|
||||||
/// The order is the whole behaviour. The store's record moves first (so a
|
/// The order is the whole behaviour. The store's record moves first (so a
|
||||||
@@ -667,7 +668,7 @@ fn submit(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) {
|
|||||||
fn run_job(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) {
|
fn run_job(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) {
|
||||||
// Cheap pre-check: a request cancelled before a worker picked it up is not
|
// 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
|
// 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.)
|
// guaranteed" is discharged by discarding the result.)
|
||||||
if conn.is_cancelled(req_id) {
|
if conn.is_cancelled(req_id) {
|
||||||
conn.forget(req_id);
|
conn.forget(req_id);
|
||||||
@@ -811,6 +812,9 @@ fn run_request(
|
|||||||
(ReplyOk::Output(h.git(&p(&cwd), &borrowed)?), Vec::new())
|
(ReplyOk::Output(h.git(&p(&cwd), &borrowed)?), Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- machine inventory ---------------------------------------------
|
||||||
|
ControlRequest::Shells => (ReplyOk::Shells(h.shells()?), Vec::new()),
|
||||||
|
|
||||||
// ----- watch ---------------------------------------------------------
|
// ----- watch ---------------------------------------------------------
|
||||||
ControlRequest::WatchOpen { dirs } => {
|
ControlRequest::WatchOpen { dirs } => {
|
||||||
let id = conn.open_watch(req_id, &paths(&dirs))?;
|
let id = conn.open_watch(req_id, &paths(&dirs))?;
|
||||||
@@ -869,7 +873,7 @@ fn run_request(
|
|||||||
(ReplyOk::Unit, Vec::new())
|
(ReplyOk::Unit, Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- attachment (design §10, D8) -----------------------------------
|
// ----- attachment (D8) -----------------------------------
|
||||||
ControlRequest::WorkspaceAttach { id } => (
|
ControlRequest::WorkspaceAttach { id } => (
|
||||||
ReplyOk::Attached {
|
ReplyOk::Attached {
|
||||||
took_over_from: attach_workspace(conn, &id, false)?,
|
took_over_from: attach_workspace(conn, &id, false)?,
|
||||||
@@ -2004,6 +2008,9 @@ mod tests {
|
|||||||
stderr: Vec::new(),
|
stderr: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
fn shells(&self) -> io::Result<crate::host::ShellInventory> {
|
||||||
|
self.inner.shells()
|
||||||
|
}
|
||||||
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub> {
|
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub> {
|
||||||
self.inner.watch(dirs)
|
self.inner.watch(dirs)
|
||||||
}
|
}
|
||||||
@@ -2186,6 +2193,7 @@ mod tests {
|
|||||||
separator: '/',
|
separator: '/',
|
||||||
home: "/root".into(),
|
home: "/root".into(),
|
||||||
features: vec![],
|
features: vec![],
|
||||||
|
instance: "other-instance".into(),
|
||||||
})
|
})
|
||||||
.encode(&mut s);
|
.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
|
/// **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.
|
/// connection exists for that one workspace that is exactly right.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dedicated_connection_is_closed_when_its_workspace_is_taken() {
|
fn a_dedicated_connection_is_closed_when_its_workspace_is_taken() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! `tty7-server` must agree on byte for byte.
|
//! `tty7-server` must agree on byte for byte.
|
||||||
//!
|
//!
|
||||||
//! **This crate must never depend on gpui.** That is the invariant the split
|
//! **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
|
//! `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`,
|
//! 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
|
//! `FontFeatures` — the data lives here and the GUI crate adds the gpui-facing
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ tty7-core = { path = "../tty7-core" }
|
|||||||
# Sandboxes for the suite: an empty directory per case, removed on drop. The
|
# 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.
|
# server is on this machine, so a local temp dir is a path in its namespace.
|
||||||
tempfile = "3"
|
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
|
# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary
|
||||||
# itself still depends on nothing but `tty7-core`.
|
# itself still depends on nothing but `tty7-core`.
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! `tty7-server` — the tty7 session daemon with no GUI attached.
|
//! `tty7-server` — the tty7 session daemon with no GUI attached.
|
||||||
//!
|
//!
|
||||||
//! This is the binary that runs on the machine a *remote* workspace lives on
|
//! 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
|
//! It runs the same
|
||||||
//! `daemon::server` the local GUI auto-spawns, plus the control listener that
|
//! `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
|
//! 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
|
//! 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
|
--pane Forward to the machine's *pane* socket instead
|
||||||
--control-sock <p> Use <p> as the control socket instead of the default
|
--control-sock <p> Use <p> as the control socket instead of the default
|
||||||
--config-dir <dir> Use <dir> for the socket, config and session files
|
--config-dir <dir> Use <dir> for the socket, config and session files
|
||||||
|
--protocol Print the dialects this binary speaks, as JSON
|
||||||
-V, --version Print the version and exit
|
-V, --version Print the version and exit
|
||||||
-h, --help Print this help and exit
|
-h, --help Print this help and exit
|
||||||
";
|
";
|
||||||
@@ -87,6 +88,22 @@ fn main() -> ExitCode {
|
|||||||
println!("tty7-server {}", env!("CARGO_PKG_VERSION"));
|
println!("tty7-server {}", env!("CARGO_PKG_VERSION"));
|
||||||
return ExitCode::SUCCESS;
|
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") {
|
if args.iter().any(|a| a == "--help" || a == "-h") {
|
||||||
print!("{USAGE}");
|
print!("{USAGE}");
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
@@ -213,7 +230,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
|||||||
// `persist` writes the whole document: the second to save
|
// `persist` writes the whole document: the second to save
|
||||||
// silently drops the first's changes. Their attachment
|
// silently drops the first's changes. Their attachment
|
||||||
// registries would be separate too, which makes design
|
// 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.
|
// hold the same workspace and neither would be told.
|
||||||
//
|
//
|
||||||
// Not attempted when the caller named a socket: starting a
|
// 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.
|
/// server, in both directions, until either side stops.
|
||||||
///
|
///
|
||||||
/// Deliberately dumb: it parses nothing. The version handshake this stream
|
/// Deliberately dumb: it parses nothing. The version handshake this stream
|
||||||
/// carries is between the *client* and the server at the far end (contract
|
/// carries is between the *client* and the server at the far end, and a bridge
|
||||||
/// §6.9), and a bridge that understood the frames would be a third opinion about
|
/// that understood the frames would be a third opinion about
|
||||||
/// the protocol version, which is exactly the coupling the design forbids.
|
/// the protocol version, which is exactly the coupling the design forbids.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
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
|
// 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
|
// 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
|
// 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
|
// the case that made this visible: the server closes the displaced session's
|
||||||
// link, and that has to reach the client through this bridge.
|
// link, and that has to reach the client through this bridge.
|
||||||
let feeder_socket = upstream.try_clone()?;
|
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.
|
/// 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:
|
/// 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
|
/// **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
|
/// shows the same thing. The client's `session.json` keeps only its own view
|
||||||
/// state.
|
/// state.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Everything `--stdio` is Unix-only — the flag is refused on Windows, where a
|
//! 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
|
//! 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.
|
//! everywhere.
|
||||||
|
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! unit-tested there.
|
//! unit-tested there.
|
||||||
|
|
||||||
// Unix-only: the hub this stands up is a Unix-domain socket, which is also the
|
// 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
|
// 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`.
|
// that differs, and `daemon::router` covers that with its own `cfg`.
|
||||||
#![cfg(unix)]
|
#![cfg(unix)]
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
|
|||||||
cwd: Some(dir.path().to_path_buf()),
|
cwd: Some(dir.path().to_path_buf()),
|
||||||
size: win(),
|
size: win(),
|
||||||
shell: Some(plain_shell()),
|
shell: Some(plain_shell()),
|
||||||
|
owner: None,
|
||||||
}
|
}
|
||||||
.encode(&mut sock)
|
.encode(&mut sock)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -258,6 +259,7 @@ fn a_routed_kill_reaches_the_pane_it_names() {
|
|||||||
cwd: Some(dir.path().to_path_buf()),
|
cwd: Some(dir.path().to_path_buf()),
|
||||||
size: win(),
|
size: win(),
|
||||||
shell: Some(plain_shell()),
|
shell: Some(plain_shell()),
|
||||||
|
owner: None,
|
||||||
}
|
}
|
||||||
.encode(&mut sock)
|
.encode(&mut sock)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
// Unix-only: every case here is a `--stdio` child, and `--stdio` is refused on
|
// 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
|
// 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)]
|
#![cfg(unix)]
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//!
|
//!
|
||||||
//! | | Why an in-process socket pair would not do |
|
//! | | 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` |
|
//! | `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 |
|
//! | 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);
|
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
|
/// 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
|
/// 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.
|
// The displaced client is told which workspace it lost and to whom.
|
||||||
laptop.expect_preempted("w", "desktop");
|
laptop.expect_preempted("w", "desktop");
|
||||||
// …and then its link is closed, because this connection existed for that
|
// …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);
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
while laptop.control.is_connected() {
|
while laptop.control.is_connected() {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -499,7 +499,7 @@ fn bridged(sock: &Path, token: &str) -> Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [`bridged`], naming the client machine and, optionally, the workspace this
|
/// [`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 {
|
fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client {
|
||||||
let hello = ControlHello {
|
let hello = ControlHello {
|
||||||
control_version: tty7_core::daemon::control::CONTROL_VERSION,
|
control_version: tty7_core::daemon::control::CONTROL_VERSION,
|
||||||
|
|||||||
@@ -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 <distro> -- 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<Vec<Entry>>; // Entry 带 ignored 标记
|
|
||||||
fn stat(&self, p: &Path) -> io::Result<Meta>; // 含 mtime
|
|
||||||
fn read_file(&self, p: &Path) -> io::Result<Vec<u8>>;
|
|
||||||
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<Option<PathBuf>>;
|
|
||||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
|
|
||||||
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<dyn Host>`,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<RemoteRef>, // None = 本地,语义与今天完全一致
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
远程条目的 `session` 字段在客户端留空——布局的权威在远程,连上之后拉。旧的 `session.json` 没有 `host` 字段,反序列化后全是 `None`,即全部是本地 workspace,与今天行为逐字相同。
|
|
||||||
|
|
||||||
`RemoteRef` 指向一个已存的 SSH 配置(profile id 或 `~/.ssh/config` alias 或 `user@host:port`)加上远程侧的 `WorkspaceId`。
|
|
||||||
|
|
||||||
**`HostId`** 是客户端进程内对一个 `Arc<dyn Host>` 的稳定标识:本地是一个固定值,远程由 `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<String, bool>`,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-<ver>.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$\<distro>\home\<user>\.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 <agent> <event>
|
|
||||||
└─ 写 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 主机一致 |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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-<target-triple>
|
|
||||||
```
|
|
||||||
|
|
||||||
| 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-<triple>` 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/<tag>/<asset>
|
|
||||||
```
|
|
||||||
|
|
||||||
| Client version | `<tag>` |
|
|
||||||
|---|---|
|
|
||||||
| `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-<ver>.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 | `<dir of tty7.exe>\server\<asset>`, in both the installer and the portable zip |
|
|
||||||
| Who looks there | `daemon::install::wsl` — `BUNDLED_SUBDIR`; it also accepts `<dir of tty7.exe>\<asset>`, 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.
|
|
||||||
+313
-12
@@ -84,6 +84,7 @@ impl WorkspaceStore {
|
|||||||
// workspace whose machine is unreachable must open empty. See
|
// workspace whose machine is unreachable must open empty. See
|
||||||
// [`claimable_session`].
|
// [`claimable_session`].
|
||||||
let reachable = id.is_none_or(|id| Self::machine_is_connected(cx, id));
|
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 {
|
let Some(store) = Self::try_store(cx) else {
|
||||||
// No store (tests): hand back a detached identity so the window
|
// No store (tests): hand back a detached identity so the window
|
||||||
// still builds, but nothing is persisted.
|
// still builds, but nothing is persisted.
|
||||||
@@ -99,7 +100,10 @@ impl WorkspaceStore {
|
|||||||
};
|
};
|
||||||
workspace.open = true;
|
workspace.open = true;
|
||||||
workspace.touch();
|
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.active = Some(claimed.0);
|
||||||
store.workspaces.save();
|
store.workspaces.save();
|
||||||
claimed
|
claimed
|
||||||
@@ -118,6 +122,7 @@ impl WorkspaceStore {
|
|||||||
// describing that machine's layout, so it does not get to overwrite the
|
// describing that machine's layout, so it does not get to overwrite the
|
||||||
// copy we have of it — see [`record_session`].
|
// copy we have of it — see [`record_session`].
|
||||||
let reachable = Self::machine_is_connected(cx, id);
|
let reachable = Self::machine_is_connected(cx, id);
|
||||||
|
let instance = Self::serving_instance(cx, Some(id));
|
||||||
let Some(store) = Self::try_store(cx) else {
|
let Some(store) = Self::try_store(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -126,7 +131,7 @@ impl WorkspaceStore {
|
|||||||
// tearing down); nothing to record.
|
// tearing down); nothing to record.
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
record_session(workspace, session, reachable);
|
record_session(workspace, session, reachable, instance);
|
||||||
if let Some(window) = window {
|
if let Some(window) = window {
|
||||||
workspace.window = Some(window);
|
workspace.window = Some(window);
|
||||||
}
|
}
|
||||||
@@ -201,6 +206,29 @@ impl WorkspaceStore {
|
|||||||
store.workspaces.save();
|
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.
|
/// Forget a workspace entirely — the explicit "Close Workspace" action.
|
||||||
/// The caller is responsible for killing its daemon panes first; this only
|
/// The caller is responsible for killing its daemon panes first; this only
|
||||||
/// drops the bookkeeping.
|
/// drops the bookkeeping.
|
||||||
@@ -215,7 +243,7 @@ impl WorkspaceStore {
|
|||||||
store.workspaces.save();
|
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
|
/// 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
|
/// 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()
|
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<WorkspaceId>) -> Option<String> {
|
||||||
|
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
|
/// The client-side entry for `host` — the existing one if this machine has
|
||||||
/// seen that workspace before, a fresh one otherwise.
|
/// 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
|
/// touching the cached layout**, and
|
||||||
/// [`crate::ui::remote_workspace`]'s connect path rebuilds the window the moment
|
/// [`crate::ui::remote_workspace`]'s connect path rebuilds the window the moment
|
||||||
/// the machine answers.
|
/// 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 {
|
if workspace.is_remote() && !reachable {
|
||||||
return Session::default();
|
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()
|
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.
|
/// 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
|
/// The remote's own `workspaces.json` is still the authority; this entry is the
|
||||||
/// cache the next launch opens from.
|
/// 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<String>,
|
||||||
|
) {
|
||||||
if workspace.is_remote() && !reachable {
|
if workspace.is_remote() && !reachable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
workspace.session = session;
|
workspace.session = session;
|
||||||
|
workspace.daemon_instance = instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -427,7 +548,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_local_workspace_stores_its_own_layout() {
|
fn a_local_workspace_stores_its_own_layout() {
|
||||||
let mut workspace = Workspace::default();
|
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.session.tabs.len(), 1);
|
||||||
assert_eq!(workspace.pane_ids(), vec![7]);
|
assert_eq!(workspace.pane_ids(), vec![7]);
|
||||||
}
|
}
|
||||||
@@ -439,7 +560,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_connected_remote_workspace_stores_its_layout() {
|
fn a_connected_remote_workspace_stores_its_layout() {
|
||||||
let mut workspace = Workspace::on_remote(remote_ref());
|
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.session.tabs.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace.pane_ids(),
|
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.
|
/// A local workspace opens on the layout it saved.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_local_workspace_reopens_its_saved_layout() {
|
fn a_local_workspace_reopens_its_saved_layout() {
|
||||||
@@ -455,19 +605,55 @@ mod tests {
|
|||||||
session: local_layout(),
|
session: local_layout(),
|
||||||
..Workspace::default()
|
..Workspace::default()
|
||||||
};
|
};
|
||||||
let claimed = claimable_session(&mut workspace, true);
|
let claimed = claimable_session(&mut workspace, true, None);
|
||||||
assert_eq!(claimed.tabs.len(), 1);
|
assert_eq!(claimed.tabs.len(), 1);
|
||||||
// And the entry is left alone.
|
// And the entry is left alone.
|
||||||
assert_eq!(workspace.session.tabs.len(), 1);
|
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
|
/// A connected remote workspace reopens on the layout its machine last
|
||||||
/// reported — the read half of "reconnecting gets my tabs back".
|
/// reported — the read half of "reconnecting gets my tabs back".
|
||||||
#[test]
|
#[test]
|
||||||
fn a_connected_remote_workspace_reopens_its_layout() {
|
fn a_connected_remote_workspace_reopens_its_layout() {
|
||||||
let mut workspace = Workspace::on_remote(remote_ref());
|
let mut workspace = Workspace::on_remote(remote_ref());
|
||||||
workspace.session = local_layout();
|
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!(claimed.tabs.len(), 1);
|
||||||
assert_eq!(workspace.session.tabs.len(), 1);
|
assert_eq!(workspace.session.tabs.len(), 1);
|
||||||
}
|
}
|
||||||
@@ -482,7 +668,7 @@ mod tests {
|
|||||||
let mut workspace = Workspace::on_remote(remote_ref());
|
let mut workspace = Workspace::on_remote(remote_ref());
|
||||||
workspace.session = local_layout();
|
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!(claimed.tabs.is_empty(), "the window must open with no tabs");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace.session.tabs.len(),
|
workspace.session.tabs.len(),
|
||||||
@@ -498,7 +684,7 @@ mod tests {
|
|||||||
fn an_unreachable_remote_window_does_not_overwrite_the_cached_layout() {
|
fn an_unreachable_remote_window_does_not_overwrite_the_cached_layout() {
|
||||||
let mut workspace = Workspace::on_remote(remote_ref());
|
let mut workspace = Workspace::on_remote(remote_ref());
|
||||||
workspace.session = local_layout();
|
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);
|
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
|
/// The remote-bound payload travels under the *remote's* id, so a record
|
||||||
/// pushed and pulled back names the same workspace both times.
|
/// pushed and pulled back names the same workspace both times.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -575,7 +876,7 @@ mod tests {
|
|||||||
|
|
||||||
/// **The window/host invariant, as a test.**
|
/// **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
|
/// *never do this*, and the M5 data layer spends that guarantee — a
|
||||||
/// workspace stores `host` once instead of per pane, and `sidebar_group`
|
/// workspace stores `host` once instead of per pane, and `sidebar_group`
|
||||||
/// stays a bare `PathBuf` — so it has to be nailed down rather than
|
/// stays a bare `PathBuf` — so it has to be nailed down rather than
|
||||||
|
|||||||
+301
-22
@@ -138,7 +138,7 @@ struct ReaderSignals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The remote workspace a pane belongs to, and how the local daemon reaches its
|
/// 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
|
/// 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
|
/// about it is addressable here by `pane_id`. This is what a pane carries
|
||||||
@@ -166,7 +166,7 @@ pub struct PaneWorkspace {
|
|||||||
|
|
||||||
impl PaneWorkspace {
|
impl PaneWorkspace {
|
||||||
/// Whether this workspace shares `localhost` with the client, so a
|
/// 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).
|
/// exception).
|
||||||
pub fn shares_localhost(&self) -> bool {
|
pub fn shares_localhost(&self) -> bool {
|
||||||
matches!(self.target, crate::core::session::RemoteTarget::Wsl { .. })
|
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
|
/// 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
|
/// underneath is identical either way — the same local socket, the same
|
||||||
/// `try_clone`, the same reader thread — because the local daemon forwards a
|
/// `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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub enum PaneRoute {
|
pub enum PaneRoute {
|
||||||
/// This machine's daemon. Every pane before remote workspaces existed, and
|
/// This machine's daemon. Every pane before remote workspaces existed, and
|
||||||
@@ -398,7 +398,7 @@ impl RemoteTerminal {
|
|||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
shell: Option<ShellSpec>,
|
shell: Option<ShellSpec>,
|
||||||
) -> anyhow::Result<(Self, u64)> {
|
) -> 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.
|
/// [`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
|
/// 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
|
/// remote daemon that is missing or mismatched is `install`'s business, and
|
||||||
/// it has already run by the time the ack arrives.
|
/// 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(
|
pub fn spawn_on(
|
||||||
route: &PaneRoute,
|
route: &PaneRoute,
|
||||||
size: TermSize,
|
size: TermSize,
|
||||||
@@ -416,10 +421,12 @@ impl RemoteTerminal {
|
|||||||
cell_h: u16,
|
cell_h: u16,
|
||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
shell: Option<ShellSpec>,
|
shell: Option<ShellSpec>,
|
||||||
|
owner: Option<String>,
|
||||||
) -> anyhow::Result<(Self, u64)> {
|
) -> anyhow::Result<(Self, u64)> {
|
||||||
let retry_cwd = cwd.clone();
|
let retry_cwd = cwd.clone();
|
||||||
let retry_shell = shell.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),
|
Ok(term) => Ok(term),
|
||||||
Err(first_err) if daemon_not_listening(&first_err) => {
|
Err(first_err) if daemon_not_listening(&first_err) => {
|
||||||
// Nothing is on the socket: the daemon died (crash, OOM, a stray
|
// 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}"
|
"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(
|
Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner)
|
||||||
|second_err| {
|
.map_err(|second_err| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
|
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// **Local panes only.** On a routed pane the connection this reads
|
// **Local panes only.** On a routed pane the connection this reads
|
||||||
// as "disconnected" belongs to the *remote* — the local daemon is
|
// 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}"
|
"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!(
|
anyhow::anyhow!(
|
||||||
"daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}"
|
"daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}"
|
||||||
)
|
)
|
||||||
@@ -475,10 +481,22 @@ impl RemoteTerminal {
|
|||||||
cell_h: u16,
|
cell_h: u16,
|
||||||
cwd: Option<PathBuf>,
|
cwd: Option<PathBuf>,
|
||||||
shell: Option<ShellSpec>,
|
shell: Option<ShellSpec>,
|
||||||
|
owner: Option<String>,
|
||||||
) -> anyhow::Result<(Self, u64)> {
|
) -> anyhow::Result<(Self, u64)> {
|
||||||
let mut stream = connect_routed(route)?;
|
let mut stream = connect_routed(route)?;
|
||||||
let win = win_size(size, cell_w, cell_h);
|
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
|
// 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,
|
// very next frames on this connection are this pane's Snapshot + Output,
|
||||||
// which the reader thread (started below) will consume.
|
// which the reader thread (started below) will consume.
|
||||||
@@ -486,6 +504,7 @@ impl RemoteTerminal {
|
|||||||
cwd,
|
cwd,
|
||||||
size: win,
|
size: win,
|
||||||
shell,
|
shell,
|
||||||
|
owner,
|
||||||
}
|
}
|
||||||
.encode(&mut stream)?;
|
.encode(&mut stream)?;
|
||||||
let pane_id = match DaemonMsg::read(&mut stream)? {
|
let pane_id = match DaemonMsg::read(&mut stream)? {
|
||||||
@@ -527,16 +546,17 @@ impl RemoteTerminal {
|
|||||||
let mut stream = connect_routed(route)?;
|
let mut stream = connect_routed(route)?;
|
||||||
let win = win_size(size, cell_w, cell_h);
|
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)?;
|
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();
|
term.route = route.clone();
|
||||||
Ok(term)
|
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
|
// For one pane: **reopen the channel, `Attach`, take the replay, resize to
|
||||||
// this client's geometry.** It happens *in place* — the same `Term`, the
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// and **the middle is genuinely gone**. Nothing here interpolates it, and
|
||||||
/// nothing upstream may imply it will fill in later.
|
/// nothing upstream may imply it will fill in later.
|
||||||
@@ -626,6 +646,12 @@ impl RemoteTerminal {
|
|||||||
self.term.clone(),
|
self.term.clone(),
|
||||||
self.proxy.clone(),
|
self.proxy.clone(),
|
||||||
read_half,
|
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 {
|
ReaderSignals {
|
||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
shell: self.shell_state.clone(),
|
shell: self.shell_state.clone(),
|
||||||
@@ -646,7 +672,7 @@ impl RemoteTerminal {
|
|||||||
}
|
}
|
||||||
self.reader_thread = Some(reader);
|
self.reader_thread = Some(reader);
|
||||||
self.route = route.clone();
|
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
|
// size but deliberately does not resize the PTY, so the geometry only
|
||||||
// becomes real when this frame lands — and `synced_size = false` is what
|
// becomes real when this frame lands — and `synced_size = false` is what
|
||||||
// lets it through when the size happens to equal the last one.
|
// 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
|
/// Shared tail of `spawn`/`attach`: build the local `Term`, split the socket
|
||||||
/// into read/write halves, and launch the reader thread.
|
/// into read/write halves, and launch the reader thread.
|
||||||
pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result<Self> {
|
pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result<Self> {
|
||||||
|
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<u8>,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
// Two independent handles to the same connection: the reader thread owns
|
// Two independent handles to the same connection: the reader thread owns
|
||||||
// the read half, the UI thread writes through the (mutex-guarded) write
|
// the read half, the UI thread writes through the (mutex-guarded) write
|
||||||
// half. Reads and writes are independent directions, so this is safe.
|
// half. Reads and writes are independent directions, so this is safe.
|
||||||
@@ -696,6 +734,7 @@ impl RemoteTerminal {
|
|||||||
term.clone(),
|
term.clone(),
|
||||||
proxy.clone(),
|
proxy.clone(),
|
||||||
read_half,
|
read_half,
|
||||||
|
buffered,
|
||||||
ReaderSignals {
|
ReaderSignals {
|
||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
shell: shell_state.clone(),
|
shell: shell_state.clone(),
|
||||||
@@ -744,7 +783,7 @@ impl RemoteTerminal {
|
|||||||
|
|
||||||
/// Close this pane's link, leaving the pane running on its machine.
|
/// 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
|
/// takeover needs the client to *stop being attached* while the view stays
|
||||||
/// on screen in its read-only state.
|
/// on screen in its read-only state.
|
||||||
pub fn detach_link(&mut self) {
|
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
|
// 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
|
// 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.
|
// path that is already exercised every time a connection fails.
|
||||||
if let Some(handle) = self.reader_thread.take() {
|
if let Some(handle) = self.reader_thread.take() {
|
||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
@@ -783,6 +822,9 @@ impl RemoteTerminal {
|
|||||||
term: Arc<FairMutex<Term<EventProxy>>>,
|
term: Arc<FairMutex<Term<EventProxy>>>,
|
||||||
proxy: EventProxy,
|
proxy: EventProxy,
|
||||||
read_half: Stream,
|
read_half: Stream,
|
||||||
|
// Bytes already off the socket (see `from_stream_with`), which the loop
|
||||||
|
// resumes from before its first read.
|
||||||
|
buffered: Vec<u8>,
|
||||||
signals: ReaderSignals,
|
signals: ReaderSignals,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -842,7 +884,7 @@ impl RemoteTerminal {
|
|||||||
// applied history, and the next pair's Size (ultimately the
|
// applied history, and the next pair's Size (ultimately the
|
||||||
// final pair, which carries the PTY's current geometry)
|
// final pair, which carries the PTY's current geometry)
|
||||||
// restores the recorded width before more bytes advance.
|
// restores the recorded width before more bytes advance.
|
||||||
let mut pending: Vec<u8> = Vec::new();
|
let mut pending: Vec<u8> = buffered;
|
||||||
let mut pending_size: Option<WinSize> = None;
|
let mut pending_size: Option<WinSize> = None;
|
||||||
// Sized to the daemon writer's coalesced-frame cap so one large
|
// Sized to the daemon writer's coalesced-frame cap so one large
|
||||||
// Output frame lands in a few reads instead of dozens.
|
// Output frame lands in a few reads instead of dozens.
|
||||||
@@ -1798,7 +1840,7 @@ impl RemoteTerminal {
|
|||||||
query(pane_id).unwrap_or_default()
|
query(pane_id).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Remote workspaces (design §15) ───────────────────────────────────────
|
// ── Remote workspaces ───────────────────────────────────────
|
||||||
|
|
||||||
/// Send one workspace-scoped request and return the daemon's reply.
|
/// 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<Vec<u8>> {
|
||||||
|
use std::io::Read as _;
|
||||||
|
|
||||||
|
let _ = stream.set_read_timeout(Some(wait));
|
||||||
|
let mut buffered: Vec<u8> = 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<u8>,
|
||||||
|
wait: std::time::Duration,
|
||||||
|
) -> Option<String> {
|
||||||
|
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 {
|
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
|
||||||
err.chain().any(|cause| {
|
err.chain().any(|cause| {
|
||||||
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
||||||
@@ -2253,7 +2428,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// WSL routes by distro and carries no spec, because there is no connection
|
/// WSL routes by distro and carries no spec, because there is no connection
|
||||||
/// to name (design §7.3).
|
/// to name.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_wsl_workspace_routes_by_distro() {
|
fn a_wsl_workspace_routes_by_distro() {
|
||||||
let ws = PaneWorkspace {
|
let ws = PaneWorkspace {
|
||||||
@@ -2500,6 +2675,110 @@ mod tests {
|
|||||||
assert!(!daemon_not_listening(&eof));
|
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`
|
/// 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
|
/// 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
|
/// were the attach'd socket) and push framed `DaemonMsg`s down the other, then
|
||||||
|
|||||||
+208
-30
@@ -90,6 +90,22 @@ pub struct AuthPromptReady;
|
|||||||
|
|
||||||
impl gpui::EventEmitter<AuthPromptReady> for TerminalView {}
|
impl gpui::EventEmitter<AuthPromptReady> 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<AgentSessionChanged> for TerminalView {}
|
||||||
|
|
||||||
/// An established native-SSH daemon pane, ready to be wrapped in a view: the
|
/// An established native-SSH daemon pane, ready to be wrapped in a view: the
|
||||||
/// output of the fallible [`TerminalView::spawn_native_ssh_terminal`], consumed
|
/// output of the fallible [`TerminalView::spawn_native_ssh_terminal`], consumed
|
||||||
/// by the infallible [`TerminalView::from_native_ssh_parts`].
|
/// 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
|
/// Readable for the same reason as `pane_id` above: killing an orphaned
|
||||||
/// pane has to dial the machine it actually landed on.
|
/// pane has to dial the machine it actually landed on.
|
||||||
pub(crate) workspace: Option<crate::terminal::PaneWorkspace>,
|
pub(crate) workspace: Option<crate::terminal::PaneWorkspace>,
|
||||||
|
/// 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<crate::core::session::WorkspaceId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See `TerminalView::drag_scroll`.
|
/// See `TerminalView::drag_scroll`.
|
||||||
@@ -174,7 +200,7 @@ pub struct TerminalView {
|
|||||||
/// through [`host`](Self::host) at use time means a reconnect is picked up
|
/// through [`host`](Self::host) at use time means a reconnect is picked up
|
||||||
/// by the next probe with nothing to notify.
|
/// by the next probe with nothing to notify.
|
||||||
host_id: crate::ui::host_ops::HostId,
|
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
|
/// `None` — the case today, until the M5 window/workspace binding calls
|
||||||
/// [`set_workspace`](Self::set_workspace) — means a local pane or an SSH
|
/// [`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
|
/// panes. In-memory only (not persisted) — held so splits of this pane
|
||||||
/// inherit the same shell.
|
/// inherit the same shell.
|
||||||
shell_spec: Option<ShellSpec>,
|
shell_spec: Option<ShellSpec>,
|
||||||
|
/// 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<crate::core::session::WorkspaceId>,
|
||||||
|
/// 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**
|
/// The native-SSH spec this pane was spawned with, **secrets stripped**
|
||||||
/// ([`NativeSshSpec::without_secrets`]). `None` for local shells (and a
|
/// ([`NativeSshSpec::without_secrets`]). `None` for local shells (and a
|
||||||
/// foreground `ssh` typed in one). Persisted into the session so a *dead*
|
/// 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
|
/// waiting, working → done) fire exactly one notification each and repaint
|
||||||
/// the status dot.
|
/// the status dot.
|
||||||
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
|
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
|
||||||
|
/// 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<String>, Option<Vec<String>>),
|
||||||
/// When the current rich turn entered `Working`, for the "finished after
|
/// When the current rich turn entered `Working`, for the "finished after
|
||||||
/// Ns" copy on its `Done` notification.
|
/// Ns" copy on its `Done` notification.
|
||||||
agent_turn_started: Option<std::time::Instant>,
|
agent_turn_started: Option<std::time::Instant>,
|
||||||
@@ -510,7 +561,7 @@ enum LoopbackOpen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How a ⌘/Ctrl-clicked URL should be opened, decided before anything is done
|
/// 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
|
/// 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
|
/// 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.
|
/// machine: hand the URL to the OS unchanged.
|
||||||
Direct,
|
Direct,
|
||||||
/// A remote whose `localhost` *is* the client's — WSL shares the network
|
/// 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
|
/// built; the original URL already resolves. Wired now so M8 only has to
|
||||||
/// start constructing `RemoteTarget::Wsl`.
|
/// start constructing `RemoteTarget::Wsl`.
|
||||||
NoForwardNeeded,
|
NoForwardNeeded,
|
||||||
@@ -998,8 +1049,18 @@ impl TerminalView {
|
|||||||
/// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
/// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
||||||
/// re-`attach` to the still-running pane so its process + scrollback come
|
/// re-`attach` to the still-running pane so its process + scrollback come
|
||||||
/// back intact; otherwise `spawn` a fresh pane (with the caller's shell
|
/// 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
|
/// pick, if any).
|
||||||
/// confirmed alive, so we trust it here.
|
///
|
||||||
|
/// **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** —
|
/// **`workspace: None` is the local path, unchanged down to the byte** —
|
||||||
/// [`PaneRoute::for_workspace`] answers `Local`, and `Local` is a bare
|
/// [`PaneRoute::for_workspace`] answers `Local`, and `Local` is a bare
|
||||||
@@ -1015,16 +1076,24 @@ impl TerminalView {
|
|||||||
working_directory: Option<std::path::PathBuf>,
|
working_directory: Option<std::path::PathBuf>,
|
||||||
restore_pane: Option<u64>,
|
restore_pane: Option<u64>,
|
||||||
shell: Option<ShellSpec>,
|
shell: Option<ShellSpec>,
|
||||||
|
owner: Option<crate::core::session::WorkspaceId>,
|
||||||
) -> anyhow::Result<ShellParts> {
|
) -> anyhow::Result<ShellParts> {
|
||||||
let route = crate::terminal::PaneRoute::for_workspace(workspace.as_ref());
|
let route = crate::terminal::PaneRoute::for_workspace(workspace.as_ref());
|
||||||
let (terminal, pane_id, shell_spec) = match restore_pane {
|
let attached = match restore_pane {
|
||||||
Some(id) => (
|
Some(id) => match RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id) {
|
||||||
RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id)?,
|
|
||||||
id,
|
|
||||||
// An attached pane keeps whatever shell it already runs; the
|
// An attached pane keeps whatever shell it already runs; the
|
||||||
// pick that spawned it (if any) isn't persisted.
|
// 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 => {
|
None => {
|
||||||
let (terminal, id) = RemoteTerminal::spawn_on(
|
let (terminal, id) = RemoteTerminal::spawn_on(
|
||||||
&route,
|
&route,
|
||||||
@@ -1033,6 +1102,7 @@ impl TerminalView {
|
|||||||
17,
|
17,
|
||||||
working_directory,
|
working_directory,
|
||||||
shell.clone(),
|
shell.clone(),
|
||||||
|
owner.map(|id| id.to_string()),
|
||||||
)?;
|
)?;
|
||||||
(terminal, id, shell)
|
(terminal, id, shell)
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1112,8 @@ impl TerminalView {
|
|||||||
pane_id,
|
pane_id,
|
||||||
shell_spec,
|
shell_spec,
|
||||||
workspace,
|
workspace,
|
||||||
|
restored,
|
||||||
|
owner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,10 +1126,25 @@ impl TerminalView {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
|
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
|
||||||
view.shell_spec = parts.shell_spec;
|
view.shell_spec = parts.shell_spec;
|
||||||
|
view.owner_workspace = parts.owner;
|
||||||
|
view.restored = parts.restored;
|
||||||
view.set_workspace(parts.workspace);
|
view.set_workspace(parts.workspace);
|
||||||
view
|
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<crate::core::session::WorkspaceId> {
|
||||||
|
self.owner_workspace
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a native (russh) SSH pane for `spec` and build the view around it
|
/// 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
|
/// (PRD FR-C1/E-series). The caller (`ui::ssh_connect`) has already resolved
|
||||||
/// keychain secrets into `spec`; this view retains only the **secret-free**
|
/// keychain secrets into `spec`; this view retains only the **secret-free**
|
||||||
@@ -1293,6 +1380,8 @@ impl TerminalView {
|
|||||||
workspace: None,
|
workspace: None,
|
||||||
pane_id,
|
pane_id,
|
||||||
shell_spec: None,
|
shell_spec: None,
|
||||||
|
owner_workspace: None,
|
||||||
|
restored: false,
|
||||||
ssh_spec: None,
|
ssh_spec: None,
|
||||||
focus_handle,
|
focus_handle,
|
||||||
font,
|
font,
|
||||||
@@ -1328,6 +1417,12 @@ impl TerminalView {
|
|||||||
running_title: String::new(),
|
running_title: String::new(),
|
||||||
running_agent: None,
|
running_agent: None,
|
||||||
last_agent_status: 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_turn_started: None,
|
||||||
agent_was_rich: false,
|
agent_was_rich: false,
|
||||||
agent_result_unread: false,
|
agent_result_unread: false,
|
||||||
@@ -1431,7 +1526,7 @@ impl TerminalView {
|
|||||||
/// - **`remote_context`** — the pane's *own* process is elsewhere (a pane
|
/// - **`remote_context`** — the pane's *own* process is elsewhere (a pane
|
||||||
/// tty7 dialled over SSH, a `wsl.exe` pane, a foreground `ssh`). The
|
/// tty7 dialled over SSH, a `wsl.exe` pane, a foreground `ssh`). The
|
||||||
/// daemon reports it, having watched the process.
|
/// 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
|
/// 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
|
/// of view*: `tty7-server` on the far machine spawned an ordinary local
|
||||||
/// shell and reports `remote_context: None`, exactly as a local daemon
|
/// 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.
|
/// what "+", a split, and the persisted session hand the new shell.
|
||||||
///
|
///
|
||||||
/// Deliberately **not** [`local_cwd`](Self::local_cwd), and the difference
|
/// 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
|
/// 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
|
/// pane that is the far box, where `/home/me/proj` is exactly right and
|
||||||
/// withholding it would open every new tab at `$HOME` instead.
|
/// 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
|
/// 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> {
|
pub fn workspace(&self) -> Option<&crate::terminal::PaneWorkspace> {
|
||||||
self.workspace.as_ref()
|
self.workspace.as_ref()
|
||||||
}
|
}
|
||||||
@@ -1527,7 +1622,7 @@ impl TerminalView {
|
|||||||
crate::terminal::PaneRoute::for_workspace(self.workspace.as_ref())
|
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,
|
/// **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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
// read the same and the wording is unchanged; for a remote
|
||||||
// workspace they are opposite facts, and "process exited" on a
|
// workspace they are opposite facts, and "process exited" on a
|
||||||
// pane whose shell is still running on the far machine is the
|
// 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.
|
// promise is that the work is still there when the link returns.
|
||||||
self.title = if self.workspace().is_some() && !self.terminal.child_exited() {
|
self.title = if self.workspace().is_some() && !self.terminal.child_exited() {
|
||||||
"tty7 — disconnected".to_string()
|
"tty7 — disconnected".to_string()
|
||||||
@@ -1881,7 +1976,7 @@ impl TerminalView {
|
|||||||
// early return is unchanged — a local pane's link only dies when its
|
// early return is unchanged — a local pane's link only dies when its
|
||||||
// daemon does.
|
// 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
|
// degrade is precisely the case where the link is gone and the shell is
|
||||||
// not: that window must keep scrolling, selecting, copying and
|
// not: that window must keep scrolling, selecting, copying and
|
||||||
// searching, and every one of those runs below this line. What must not
|
// searching, and every one of those runs below this line. What must not
|
||||||
@@ -1980,12 +2075,12 @@ impl TerminalView {
|
|||||||
return;
|
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**.
|
// this function**.
|
||||||
//
|
//
|
||||||
// Everything above is the window's own keyboard, not the machine's:
|
// 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.
|
// ⌘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
|
// down — "能滚历史、能选能复制、能 ⌘F 搜索" — and a gate at the top of
|
||||||
// `on_key_down` would silently take them all away, turning a read-only
|
// `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
|
// window into an inert one. (⌘V is not an exception that needs handling
|
||||||
@@ -3563,6 +3658,18 @@ impl TerminalView {
|
|||||||
self.agent_was_rich = false;
|
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);
|
let status = session.as_ref().map(|s| s.status);
|
||||||
if status == self.last_agent_status {
|
if status == self.last_agent_status {
|
||||||
return false;
|
return false;
|
||||||
@@ -4618,7 +4725,7 @@ impl TerminalView {
|
|||||||
/// Two shapes qualify, and they are found by different signals:
|
/// Two shapes qualify, and they are found by different signals:
|
||||||
/// - a **native-SSH pane** — tty7 dialled it, so `remote_context` says so
|
/// - a **native-SSH pane** — tty7 dialled it, so `remote_context` says so
|
||||||
/// and the daemon holds the authenticated connection under its pane id;
|
/// 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`
|
/// an ordinary local pane (it *is* one, over there), so `remote_context`
|
||||||
/// is `None` and only this side's `workspace` binding reveals it. The
|
/// is `None` and only this side's `workspace` binding reveals it. The
|
||||||
/// connection is the workspace's, not the pane's.
|
/// connection is the workspace's, not the pane's.
|
||||||
@@ -5376,7 +5483,7 @@ impl TerminalView {
|
|||||||
return LoopbackOpen::NotLoopback;
|
return LoopbackOpen::NotLoopback;
|
||||||
};
|
};
|
||||||
// WSL shares the Windows host's `localhost`, so the URL already points at
|
// 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) {
|
if matches!(plan, LoopbackPlan::NoForwardNeeded) {
|
||||||
return LoopbackOpen::NotLoopback;
|
return LoopbackOpen::NotLoopback;
|
||||||
}
|
}
|
||||||
@@ -7026,7 +7133,7 @@ mod tests {
|
|||||||
use gpui_component::IconName;
|
use gpui_component::IconName;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
// ── ⌘-click `localhost:PORT` routing (design §15) ────────────────────────
|
// ── ⌘-click `localhost:PORT` routing ────────────────────────
|
||||||
|
|
||||||
use crate::core::session::{RemoteTarget, WorkspaceId};
|
use crate::core::session::{RemoteTarget, WorkspaceId};
|
||||||
use crate::daemon::protocol::RemoteKind;
|
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
|
/// Windows host, so the URL already resolves — building a forward would be
|
||||||
/// pure overhead. Wired now; M8 supplies the target.
|
/// pure overhead. Wired now; M8 supplies the target.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -8062,6 +8169,77 @@ mod gpui_tests {
|
|||||||
panic!("the prompt report never reached the view");
|
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
|
/// 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).
|
/// longer has once the pane shrinks (a vertical split, a smaller window).
|
||||||
/// Resolving it must decline rather than index the grid — this path runs
|
/// 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());
|
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
|
/// Install a store holding one remote workspace with no connection, and
|
||||||
/// bind `view` to it. `RemoteLinks` has never heard of the machine, so
|
/// bind `view` to it. `RemoteLinks` has never heard of the machine, so
|
||||||
@@ -9546,7 +9724,7 @@ mod gpui_tests {
|
|||||||
id
|
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
|
/// selects and searches — but typing goes nowhere**, and nothing is
|
||||||
/// buffered for later (D6).
|
/// 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
|
/// `on_key_down` would silently destroy: **a read-only window is not an
|
||||||
/// inert one.**
|
/// inert one.**
|
||||||
///
|
///
|
||||||
@@ -9732,7 +9910,7 @@ mod gpui_tests {
|
|||||||
assert_eq!(next_input(&mut daemon), b"z".to_vec());
|
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`,
|
/// 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
|
/// 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"
|
"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 {
|
let resize = loop {
|
||||||
match ClientMsg::read(&mut new_daemon).expect("the new socket is live") {
|
match ClientMsg::read(&mut new_daemon).expect("the new socket is live") {
|
||||||
ClientMsg::Resize(win) => break win,
|
ClientMsg::Resize(win) => break win,
|
||||||
|
|||||||
+480
-95
@@ -24,11 +24,12 @@ use crate::core::config::{
|
|||||||
use crate::core::session::{
|
use crate::core::session::{
|
||||||
Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore,
|
Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore,
|
||||||
};
|
};
|
||||||
use crate::core::shells::DetectedShell;
|
use crate::core::shells::ShellInventory;
|
||||||
use crate::core::ssh_config;
|
use crate::core::ssh_config;
|
||||||
use crate::core::window_state::{WindowGeometry as _, WindowState};
|
use crate::core::window_state::{WindowGeometry as _, WindowState};
|
||||||
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
|
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
|
||||||
use crate::terminal::view::{ChildExited, TerminalView};
|
use crate::terminal::view::{ChildExited, TerminalView};
|
||||||
|
use crate::ui::host_registry::HostId;
|
||||||
use crate::ui::palette::{
|
use crate::ui::palette::{
|
||||||
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
|
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
|
||||||
};
|
};
|
||||||
@@ -722,10 +723,19 @@ pub struct Tty7App {
|
|||||||
/// Keeping something focused keeps keystrokes flowing through the window's
|
/// Keeping something focused keeps keystrokes flowing through the window's
|
||||||
/// dispatch path, so ⌘T & friends still reach the root action handlers.
|
/// dispatch path, so ⌘T & friends still reach the root action handlers.
|
||||||
pub(crate) home_focus: gpui::FocusHandle,
|
pub(crate) home_focus: gpui::FocusHandle,
|
||||||
/// Shells found on this machine (`core::shells::detect_shells`), listed in
|
/// The shells of the machine **this window is bound to**, listed in the "+"
|
||||||
/// the "+" dropdown. Probed once at startup off the UI thread — empty until
|
/// dropdown. Fetched once per machine off the UI thread — empty until that
|
||||||
/// that lands, when the dropdown offers just the default entry.
|
/// lands (and while a remote machine is unreachable), when the dropdown
|
||||||
pub(crate) detected_shells: Vec<DetectedShell>,
|
/// 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
|
/// 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
|
/// over the active SSH pane, but the input/editing state is app-owned so it
|
||||||
/// is not tied to the Settings tab.
|
/// is not tied to the Settings tab.
|
||||||
@@ -810,7 +820,7 @@ pub struct Tty7App {
|
|||||||
/// `focus_active`, which only takes `&self`.
|
/// `focus_active`, which only takes `&self`.
|
||||||
window_title: std::cell::RefCell<String>,
|
window_title: std::cell::RefCell<String>,
|
||||||
/// The home page's "Connect to Host" flow, or `None` when it isn't running
|
/// 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
|
/// window is what a remote workspace ends up bound to — two windows can be
|
||||||
/// reaching two different machines at once.
|
/// reaching two different machines at once.
|
||||||
pub(crate) connect: Option<crate::ui::remote_workspace::ConnectFlow>,
|
pub(crate) connect: Option<crate::ui::remote_workspace::ConnectFlow>,
|
||||||
@@ -914,7 +924,7 @@ impl Tty7App {
|
|||||||
Self::prompt_daemon_version_mismatch(window, cx);
|
Self::prompt_daemon_version_mismatch(window, cx);
|
||||||
// The same question for any *remote* server this client already found
|
// The same question for any *remote* server this client already found
|
||||||
// at a different build, and the consent handler that the install path
|
// 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);
|
crate::ui::remote_connect::register(cx);
|
||||||
Self::prompt_remote_daemon_mismatch(window, cx);
|
Self::prompt_remote_daemon_mismatch(window, cx);
|
||||||
// A window that came back on a remote workspace has its last-pulled
|
// 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
|
// First run (no session file): the very first terminal has no
|
||||||
// predecessor to inherit from, so start in the app's current
|
// predecessor to inherit from, so start in the app's current
|
||||||
// directory (None → default behavior).
|
// 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),
|
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
|
||||||
// The daemon we just tried to start isn't answering. A window
|
// 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
|
// 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
|
// A saved session (with tabs, or an empty home-page state): rebuild it
|
||||||
// the same way a daemon restart does.
|
// 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
|
// Sidebar tab filter. Each keystroke re-renders the (cheap) row list so
|
||||||
// results narrow as you type — the same live-filter wiring the theme
|
// results narrow as you type — the same live-filter wiring the theme
|
||||||
@@ -1166,7 +1185,7 @@ impl Tty7App {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let app = Self {
|
let mut app = Self {
|
||||||
tabs,
|
tabs,
|
||||||
active,
|
active,
|
||||||
font_size,
|
font_size,
|
||||||
@@ -1193,7 +1212,8 @@ impl Tty7App {
|
|||||||
mod_hint_gen: 0,
|
mod_hint_gen: 0,
|
||||||
record_gen: 0,
|
record_gen: 0,
|
||||||
home_focus: cx.focus_handle(),
|
home_focus: cx.focus_handle(),
|
||||||
detected_shells: Vec::new(),
|
shells: ShellInventory::default(),
|
||||||
|
shells_host: HostId::LOCAL,
|
||||||
loopback_panel: LoopbackForwardPanelState {
|
loopback_panel: LoopbackForwardPanelState {
|
||||||
form_pane_id: None,
|
form_pane_id: None,
|
||||||
managed: Vec::new(),
|
managed: Vec::new(),
|
||||||
@@ -1245,22 +1265,8 @@ impl Tty7App {
|
|||||||
if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 {
|
if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 {
|
||||||
crate::ui::tray::init(cx);
|
crate::ui::tray::init(cx);
|
||||||
}
|
}
|
||||||
// Discover this machine's shells for the "+" dropdown off the UI thread
|
// Fill the "+" dropdown from the machine this window is bound to.
|
||||||
// (the WSL probe on Windows spawns a process, and /etc/shells hits the
|
app.refresh_shells(cx);
|
||||||
// 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();
|
|
||||||
// Persist the session one last time as the app quits. This captures the
|
// 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
|
// latest state — including a plain `cd` that changed a pane's cwd but
|
||||||
// triggered no structural change — so the next launch restores where the
|
// 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
|
/// Called after every structural change; the write is a small synchronous
|
||||||
/// JSON dump and any error is swallowed inside `Session::save`.
|
/// JSON dump and any error is swallowed inside `Session::save`.
|
||||||
pub(crate) fn save_session(&self, cx: &mut App) {
|
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<SessionTab> = self
|
let tabs: Vec<SessionTab> = self
|
||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1447,7 +1476,7 @@ impl Tty7App {
|
|||||||
crate::ui::windows::refresh_menu(cx);
|
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
|
/// so stopping it has to end them — nothing else will. A pane's forwards
|
||||||
/// need no equivalent; the daemon drops those with the pane.
|
/// 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,
|
// 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.
|
// so it is the one thing that could carry a tab across machines.
|
||||||
self.rebind_host(previous_host, cx);
|
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 font_size = self.font_size;
|
||||||
let pane_ws = self.window_workspace(cx);
|
let pane_ws = self.window_workspace(cx);
|
||||||
let (tabs, active) =
|
let (tabs, active) = tabs_from_session(
|
||||||
tabs_from_session(pane_ws.as_ref(), Some(session), font_size, window, cx);
|
pane_ws.as_ref(),
|
||||||
|
self.workspace,
|
||||||
|
Some(session),
|
||||||
|
font_size,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
self.tabs = tabs;
|
self.tabs = tabs;
|
||||||
self.active = active;
|
self.active = active;
|
||||||
self.maximized = None;
|
self.maximized = None;
|
||||||
@@ -1627,6 +1665,7 @@ impl Tty7App {
|
|||||||
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(pane_ws.as_ref()));
|
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(pane_ws.as_ref()));
|
||||||
let Some(pane) = session_to_pane(
|
let Some(pane) = session_to_pane(
|
||||||
pane_ws.as_ref(),
|
pane_ws.as_ref(),
|
||||||
|
self.workspace,
|
||||||
&st.pane,
|
&st.pane,
|
||||||
&alive,
|
&alive,
|
||||||
self.font_size,
|
self.font_size,
|
||||||
@@ -1904,8 +1943,14 @@ impl Tty7App {
|
|||||||
.get(this.workspace)
|
.get(this.workspace)
|
||||||
.map(|w| w.session.clone());
|
.map(|w| w.session.clone());
|
||||||
let pane_ws = this.window_workspace(cx);
|
let pane_ws = this.window_workspace(cx);
|
||||||
let (tabs, active) =
|
let (tabs, active) = tabs_from_session(
|
||||||
tabs_from_session(pane_ws.as_ref(), saved, font_size, window, cx);
|
pane_ws.as_ref(),
|
||||||
|
this.workspace,
|
||||||
|
saved,
|
||||||
|
font_size,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
this.tabs = tabs;
|
this.tabs = tabs;
|
||||||
this.active = active;
|
this.active = active;
|
||||||
}
|
}
|
||||||
@@ -2503,7 +2548,7 @@ impl Tty7App {
|
|||||||
self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on);
|
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
|
/// 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
|
/// Forwards band tracks the pane the *panel* is showing, which is not
|
||||||
@@ -3087,7 +3132,7 @@ impl Tty7App {
|
|||||||
) {
|
) {
|
||||||
let parts = match parts {
|
let parts = match parts {
|
||||||
Ok(parts) => 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
|
// not collapse the split under the user, and it does not close the
|
||||||
// tab — both would throw away a layout because a network blinked.
|
// tab — both would throw away a layout because a network blinked.
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
@@ -3118,7 +3163,25 @@ impl Tty7App {
|
|||||||
// Whether the user was sitting on this pane while it connected. Read
|
// Whether the user was sitting on this pane while it connected. Read
|
||||||
// before the swap, since the placeholder leaves the tree in it.
|
// before the swap, since the placeholder leaves the tree in it.
|
||||||
let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx);
|
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);
|
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());
|
let slot = PaneSlot::Ready(view.clone());
|
||||||
self.tabs
|
self.tabs
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -3171,7 +3234,7 @@ impl Tty7App {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
// 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
|
// 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
|
// anything is spawned rather than after a local pane is already in the
|
||||||
// tab strip.
|
// tab strip.
|
||||||
@@ -3192,7 +3255,16 @@ impl Tty7App {
|
|||||||
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
|
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
|
||||||
});
|
});
|
||||||
let pane_ws = self.window_workspace(cx);
|
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,
|
Ok(view) => view,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("new tab spawn failed: {e}");
|
log::error!("new tab spawn failed: {e}");
|
||||||
@@ -3323,6 +3395,7 @@ impl Tty7App {
|
|||||||
let shell = target.read(cx).shell_spec();
|
let shell = target.read(cx).shell_spec();
|
||||||
match new_terminal(
|
match new_terminal(
|
||||||
self.window_workspace(cx),
|
self.window_workspace(cx),
|
||||||
|
Some(self.workspace),
|
||||||
self.font_size,
|
self.font_size,
|
||||||
cwd,
|
cwd,
|
||||||
None,
|
None,
|
||||||
@@ -3948,6 +4021,7 @@ impl Tty7App {
|
|||||||
};
|
};
|
||||||
let new = match new_terminal(
|
let new = match new_terminal(
|
||||||
self.window_workspace(cx),
|
self.window_workspace(cx),
|
||||||
|
Some(self.workspace),
|
||||||
self.font_size,
|
self.font_size,
|
||||||
cwd,
|
cwd,
|
||||||
None,
|
None,
|
||||||
@@ -4195,6 +4269,7 @@ impl Tty7App {
|
|||||||
) {
|
) {
|
||||||
let view = match new_terminal(
|
let view = match new_terminal(
|
||||||
self.window_workspace(cx),
|
self.window_workspace(cx),
|
||||||
|
Some(self.workspace),
|
||||||
self.font_size,
|
self.font_size,
|
||||||
Some(wt.path),
|
Some(wt.path),
|
||||||
None,
|
None,
|
||||||
@@ -6181,10 +6256,10 @@ pub(crate) mod render_probe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Tty7App {
|
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
|
/// `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
|
/// keeps showing what it had — only means anything when there is something
|
||||||
/// to keep showing. Both read the same
|
/// to keep showing. Both read the same
|
||||||
/// [`RemoteStatus::strip_message`](crate::ui::remote_workspace::RemoteStatus::strip_message),
|
/// [`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
|
/// It exists because the degrade is otherwise invisible. Everything a
|
||||||
/// disconnected window *can* still do — scroll, select, copy, ⌘F — works
|
/// 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
|
/// 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
|
/// The same shape and the same reason as
|
||||||
/// [`SftpRoute`](crate::ui::sftp::SftpRoute): resolved on the UI thread from the
|
/// [`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))
|
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).
|
/// 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
|
/// 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).
|
// Native-SSH status strip / reconnect notice (E1/E4).
|
||||||
.when_some(ssh_status, |this, el| this.child(el))
|
.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 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
|
// ssh process, this is about the machine the whole window is on, and
|
||||||
// a window can legitimately show both.
|
// 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<crate::core::cli_agent::CLIAgent>,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
launch_argv: Option<&[String]>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<String> {
|
||||||
|
if !cx.global::<Config>().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
|
/// Convert a live `Pane` tree into its serializable mirror, reading each
|
||||||
/// leaf's current cwd and each split's axis + ratio. Used when saving.
|
/// leaf's current cwd and each split's axis + ratio. Used when saving.
|
||||||
fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
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(),
|
cwd: spawn.working_directory.clone(),
|
||||||
pane_id: spawn.restore_pane,
|
pane_id: spawn.restore_pane,
|
||||||
ssh_spec: None,
|
ssh_spec: None,
|
||||||
agent: None,
|
// Written back out rather than blanked. A remote pane spends
|
||||||
agent_session_id: None,
|
// its first seconds here, and any save landing in that window
|
||||||
agent_launch_argv: None,
|
// 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)) => {
|
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
|
/// The pane ids currently alive **on `route`'s machine**, each with the
|
||||||
/// `session_to_pane` to decide per leaf whether to re-`attach` or `spawn`.
|
/// workspace that owns it (when the daemon knows — `None` for panes spawned by
|
||||||
/// Computed once per restore from that daemon's `List`; empty (→ all-fresh)
|
/// builds/daemons that predate `pane-owner`). Used by `session_to_pane` to
|
||||||
/// when it is unreachable.
|
/// 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**:
|
/// 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
|
/// 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
|
/// unrouted answer now go through
|
||||||
/// [`pane_liveness`](crate::terminal::pane_liveness), which cannot spell the
|
/// [`pane_liveness`](crate::terminal::pane_liveness), which cannot spell the
|
||||||
/// question without naming a machine.
|
/// question without naming a machine.
|
||||||
pub(crate) fn alive_panes_on(route: &crate::terminal::PaneRoute) -> std::collections::HashSet<u64> {
|
pub(crate) fn alive_panes_on(
|
||||||
|
route: &crate::terminal::PaneRoute,
|
||||||
|
) -> std::collections::HashMap<u64, Option<String>> {
|
||||||
// **Local routes only.** This is a *blocking* `List`, and every caller is on
|
// **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
|
// 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
|
// 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
|
// falls back to a fresh pane when the id is gone, which is exactly what
|
||||||
// this set was being consulted for.
|
// this set was being consulted for.
|
||||||
if !matches!(route, crate::terminal::PaneRoute::Local) {
|
if !matches!(route, crate::terminal::PaneRoute::Local) {
|
||||||
return std::collections::HashSet::new();
|
return std::collections::HashMap::new();
|
||||||
}
|
}
|
||||||
crate::terminal::RemoteTerminal::list_panes_on(route)
|
crate::terminal::RemoteTerminal::list_panes_on(route)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|p| p.alive)
|
.filter(|p| p.alive)
|
||||||
.map(|p| p.pane_id)
|
.map(|p| (p.pane_id, p.owner))
|
||||||
.collect()
|
.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<u64, Option<String>>,
|
||||||
|
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
|
/// 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
|
/// 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
|
/// 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.
|
/// stay in lockstep.
|
||||||
fn tabs_from_session(
|
fn tabs_from_session(
|
||||||
workspace: Option<&crate::terminal::PaneWorkspace>,
|
workspace: Option<&crate::terminal::PaneWorkspace>,
|
||||||
|
owner: WorkspaceId,
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
font_size: f32,
|
font_size: f32,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -7175,7 +7310,8 @@ fn tabs_from_session(
|
|||||||
for st in &session.tabs {
|
for st in &session.tabs {
|
||||||
// A tab whose every leaf failed to come back has nothing to show; drop
|
// 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).
|
// 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");
|
log::error!("dropping a restored tab: no pane in it could be started");
|
||||||
continue;
|
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.
|
/// nodes — which every tree operation ignores — in a live tab.
|
||||||
fn session_to_pane(
|
fn session_to_pane(
|
||||||
workspace: Option<&crate::terminal::PaneWorkspace>,
|
workspace: Option<&crate::terminal::PaneWorkspace>,
|
||||||
|
owner: WorkspaceId,
|
||||||
sp: &SessionPane,
|
sp: &SessionPane,
|
||||||
alive: &std::collections::HashSet<u64>,
|
alive: &std::collections::HashMap<u64, Option<String>>,
|
||||||
font_size: f32,
|
font_size: f32,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Tty7App>,
|
cx: &mut Context<Tty7App>,
|
||||||
@@ -7253,7 +7390,7 @@ fn session_to_pane(
|
|||||||
// the attempt to attach happens off the UI thread, where a dead
|
// the attempt to attach happens off the UI thread, where a dead
|
||||||
// id costs one failed round trip and falls back to a spawn.
|
// id costs one failed round trip and falls back to a spawn.
|
||||||
true => (*pane_id).filter(|_| same_daemon),
|
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)
|
// A *dead* native-SSH leaf (spec persisted, pane no longer alive)
|
||||||
// reconnects rather than dropping back to a local shell (FR-C2/E4):
|
// 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.
|
// must respawn comes back on the default shell.
|
||||||
let view = match new_terminal(
|
let view = match new_terminal(
|
||||||
workspace.cloned(),
|
workspace.cloned(),
|
||||||
|
Some(owner),
|
||||||
font_size,
|
font_size,
|
||||||
cwd.clone(),
|
cwd.clone(),
|
||||||
restore,
|
restore,
|
||||||
@@ -7293,17 +7431,37 @@ fn session_to_pane(
|
|||||||
// where it left off (cmux's auto-resume, config-gated). The bytes
|
// where it left off (cmux's auto-resume, config-gated). The bytes
|
||||||
// sit in the PTY input queue until the shell reads its first
|
// sit in the PTY input queue until the shell reads its first
|
||||||
// command — same mechanism as tmux send-keys at spawn.
|
// command — same mechanism as tmux send-keys at spawn.
|
||||||
if restore.is_none()
|
match &view {
|
||||||
&& cx.global::<Config>().restore_agent_sessions
|
// A local leaf already knows the answer, and the *view* is what
|
||||||
&& let (Some(agent), Some(id)) = (agent, agent_session_id)
|
// knows it. Not `restore.is_none()`: `alive` is read once at the
|
||||||
&& let Some(cmd) = agent.resume_command(id, agent_launch_argv.as_deref())
|
// top of the restore, so a pane that exits between that `List`
|
||||||
// A pane whose terminal has not arrived yet cannot be sent a
|
// and this attach fails into a fresh shell inside
|
||||||
// resume command. It does not need one either: `restore_pane`
|
// `spawn_shell_terminal_in` — which used to land here as
|
||||||
// travelled with the spawn, so what lands is the *same* agent
|
// "restore.is_some(), so it kept its agent", leaving an empty
|
||||||
// pane, still running its conversation.
|
// shell and a conversation nobody resumed.
|
||||||
&& let Some(terminal) = view.terminal()
|
PaneSlot::Ready(terminal) if !terminal.read(cx).restored() => {
|
||||||
{
|
if let Some(cmd) = agent_resume_command(
|
||||||
terminal.read(cx).run_command_line(&cmd);
|
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))
|
Some(Pane::leaf(view))
|
||||||
}
|
}
|
||||||
@@ -7315,8 +7473,8 @@ fn session_to_pane(
|
|||||||
// One side failing collapses the split onto the survivor, exactly
|
// One side failing collapses the split onto the survivor, exactly
|
||||||
// as closing that pane by hand would.
|
// as closing that pane by hand would.
|
||||||
match (
|
match (
|
||||||
session_to_pane(workspace, a, alive, font_size, window, cx),
|
session_to_pane(workspace, owner, a, alive, font_size, window, cx),
|
||||||
session_to_pane(workspace, b, 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(a), Some(b)) => Some(Pane::split_node(axis, *ratio, a, b)),
|
||||||
(Some(only), None) | (None, Some(only)) => Some(only),
|
(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
|
/// 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
|
/// restore `List`, a reconnect's `Attach`) goes back to it. `None` is a local
|
||||||
/// pane, byte-for-byte what it always was.
|
/// 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(
|
fn new_terminal(
|
||||||
workspace: Option<crate::terminal::PaneWorkspace>,
|
workspace: Option<crate::terminal::PaneWorkspace>,
|
||||||
|
owner: Option<WorkspaceId>,
|
||||||
font_size: f32,
|
font_size: f32,
|
||||||
working_directory: Option<std::path::PathBuf>,
|
working_directory: Option<std::path::PathBuf>,
|
||||||
restore_pane: Option<u64>,
|
restore_pane: Option<u64>,
|
||||||
@@ -7363,6 +7527,7 @@ fn new_terminal(
|
|||||||
working_directory,
|
working_directory,
|
||||||
restore_pane,
|
restore_pane,
|
||||||
shell,
|
shell,
|
||||||
|
owner,
|
||||||
)?;
|
)?;
|
||||||
return Ok(PaneSlot::Ready(build_terminal_view(
|
return Ok(PaneSlot::Ready(build_terminal_view(
|
||||||
parts, font_size, window, cx,
|
parts, font_size, window, cx,
|
||||||
@@ -7375,6 +7540,13 @@ fn new_terminal(
|
|||||||
working_directory,
|
working_directory,
|
||||||
restore_pane,
|
restore_pane,
|
||||||
shell,
|
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,
|
font_size,
|
||||||
};
|
};
|
||||||
// The machine as the user knows it. `RemoteTarget`'s `Display` is the same
|
// The machine as the user knows it. `RemoteTarget`'s `Display` is the same
|
||||||
@@ -7412,32 +7584,22 @@ fn start_pane_spawn(
|
|||||||
let parts = cx
|
let parts = cx
|
||||||
.background_executor()
|
.background_executor()
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
let attempt = |restore| {
|
// Restore, on a machine nobody asked "which panes are still
|
||||||
TerminalView::spawn_shell_terminal_in(
|
// alive?" — because asking is itself a routed round trip and
|
||||||
spawn.workspace.clone(),
|
// the UI thread is where that question used to be asked from
|
||||||
spawn.working_directory.clone(),
|
// (`alive_panes_on`). Trying the attach *is* the question, and a
|
||||||
restore,
|
// failed one falls back to a fresh pane inside
|
||||||
spawn.shell.clone(),
|
// `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
|
||||||
match spawn.restore_pane {
|
// "Try Again".
|
||||||
// Restore, on a machine nobody asked "which panes are still
|
TerminalView::spawn_shell_terminal_in(
|
||||||
// alive?" — because asking is itself a routed round trip and
|
spawn.workspace.clone(),
|
||||||
// the UI thread is where that question used to be asked from
|
spawn.working_directory.clone(),
|
||||||
// (`alive_panes_on`). Trying the attach *is* the question,
|
spawn.restore_pane,
|
||||||
// and a failed one costs one round trip on a connection this
|
spawn.shell.clone(),
|
||||||
// pane needed open anyway.
|
spawn.owner,
|
||||||
//
|
)
|
||||||
// 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),
|
|
||||||
}
|
|
||||||
// Flattened to a string here rather than carried as an
|
// Flattened to a string here rather than carried as an
|
||||||
// `anyhow::Error`: the chain is not `Send` across this await in
|
// `anyhow::Error`: the chain is not `Send` across this await in
|
||||||
// a form worth keeping, and what the pane shows is the rendered
|
// 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);
|
app.on_child_exited(view.clone(), window, cx);
|
||||||
})
|
})
|
||||||
.detach();
|
.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
|
// 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)
|
// single build site as ChildExited, so every pane (new tab, split, restore)
|
||||||
// is covered.
|
// is covered.
|
||||||
@@ -8010,10 +8183,43 @@ mod window_drag_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
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<u64, Option<String>> = [
|
||||||
|
(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
|
/// 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
|
/// 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
|
/// 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]
|
#[test]
|
||||||
fn parses_ssh_option_words_with_quotes() {
|
fn parses_ssh_option_words_with_quotes() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -8329,3 +8585,132 @@ mod keybinding_gpui_tests {
|
|||||||
assert_eq!(recording, Some(false));
|
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<Tty7App>, 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::<Tty7App>()
|
||||||
|
.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<Tty7App>,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ fn classify_external_change(
|
|||||||
/// What a landed write does to the buffer it wrote.
|
/// What a landed write does to the buffer it wrote.
|
||||||
///
|
///
|
||||||
/// Separated for the same reason: this is the three-way answer that the ⌘S
|
/// 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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
struct SaveLanding {
|
struct SaveLanding {
|
||||||
/// The buffer still holds what reached disk, so it may be marked clean.
|
/// 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
|
/// Write one buffer back to its path, optionally closing it once the write
|
||||||
/// lands.
|
/// 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
|
/// 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:
|
/// than instantly. Three things that costs us, and how each is paid:
|
||||||
///
|
///
|
||||||
@@ -1514,7 +1514,7 @@ mod tests {
|
|||||||
Some(MTime { secs, nanos })
|
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.
|
/// reload states still decide correctly now that the write is asynchronous.
|
||||||
#[test]
|
#[test]
|
||||||
fn external_changes_are_told_apart_from_our_own_saves() {
|
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.
|
/// asynchronous saving has to get right.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_landed_save_only_cleans_a_buffer_that_did_not_move() {
|
fn a_landed_save_only_cleans_a_buffer_that_did_not_move() {
|
||||||
|
|||||||
+5
-5
@@ -874,7 +874,7 @@ fn optimistic_write(
|
|||||||
/// leaves the tree showing pre-change content indefinitely, since nothing is
|
/// leaves the tree showing pre-change content indefinitely, since nothing is
|
||||||
/// left in flight or marked stale to correct it. Discarding cannot be wrong:
|
/// 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
|
/// 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
|
/// `before` is taken by value so the snapshot is consumed rather than left
|
||||||
/// lying around for a caller to misuse.
|
/// lying around for a caller to misuse.
|
||||||
@@ -909,7 +909,7 @@ impl Tty7App {
|
|||||||
/// [`spawn_host`](Tty7App::spawn_host) resolved to its host object.
|
/// [`spawn_host`](Tty7App::spawn_host) resolved to its host object.
|
||||||
///
|
///
|
||||||
/// **Derived from the window's workspace, never cached.** A window shows one
|
/// **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
|
/// 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
|
/// 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
|
/// construction. An earlier version of this re-derived the id from the
|
||||||
@@ -944,7 +944,7 @@ impl Tty7App {
|
|||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
// Only panes on the window's own machine contribute. They all are, by
|
// 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
|
// a path from another machine into the root set, and every listing of it
|
||||||
// would then be asked of the wrong host.
|
// would then be asked of the wrong host.
|
||||||
//
|
//
|
||||||
@@ -2434,7 +2434,7 @@ mod tests {
|
|||||||
let host = tty7_core::host::local::LocalHost::new();
|
let host = tty7_core::host::local::LocalHost::new();
|
||||||
// The fixture is built through the host too. Partly because it is the
|
// 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
|
// 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.
|
// 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 tmp = std::env::temp_dir().join(format!("tty7-tree-host-{}", std::process::id()));
|
||||||
let _ = host.remove(&tmp, true);
|
let _ = host.remove(&tmp, true);
|
||||||
@@ -2556,7 +2556,7 @@ mod tests {
|
|||||||
let _ = host.remove(&tmp, true);
|
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
|
/// 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
|
/// leaves the directory to relist rather than showing a row for a file that
|
||||||
/// does not exist.
|
/// does not exist.
|
||||||
|
|||||||
+4
-4
@@ -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.
|
/// 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
|
/// One sits 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
|
/// `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
|
/// 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
|
/// local window and a healthy remote one say nothing — a permanent "you are
|
||||||
/// fine" banner is noise.
|
/// fine" banner is noise.
|
||||||
@@ -263,7 +263,7 @@ impl Tty7App {
|
|||||||
let machine = self.remote_machine_label(cx);
|
let machine = self.remote_machine_label(cx);
|
||||||
let status = self.remote_status(cx)?;
|
let status = self.remote_status(cx)?;
|
||||||
let message = status.strip_message(&machine)?;
|
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 —
|
// 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
|
// this is the *empty* remote window, which is precisely the one with no
|
||||||
// other way out.
|
// other way out.
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ pub mod file_tree;
|
|||||||
pub mod forwards;
|
pub mod forwards;
|
||||||
pub mod hints;
|
pub mod hints;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
// The `Host` layer's GUI half (`docs/2026-07-27-remote-workspace-impl-contract.md`
|
// The `Host` layer's GUI half. The facade and the registry land ahead of the
|
||||||
// §5). The facade and the registry land ahead of the call sites that consume
|
// call sites that consume
|
||||||
// them — the six views move over to `HostOps` as a separate change — so they
|
// them — the six views move over to `HostOps` as a separate change — so they
|
||||||
// read as dead code until that merges.
|
// read as dead code until that merges.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
+24
-2
@@ -55,6 +55,28 @@ pub struct PendingSpawn {
|
|||||||
pub working_directory: Option<std::path::PathBuf>,
|
pub working_directory: Option<std::path::PathBuf>,
|
||||||
pub restore_pane: Option<u64>,
|
pub restore_pane: Option<u64>,
|
||||||
pub shell: Option<ShellSpec>,
|
pub shell: Option<ShellSpec>,
|
||||||
|
/// 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<crate::core::cli_agent::CLIAgent>,
|
||||||
|
pub agent_session_id: Option<String>,
|
||||||
|
pub agent_launch_argv: Option<Vec<String>>,
|
||||||
|
/// 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<crate::core::session::WorkspaceId>,
|
||||||
/// Inherited by the terminal this becomes, so a pane that arrives late
|
/// Inherited by the terminal this becomes, so a pane that arrives late
|
||||||
/// still matches the ones already on screen.
|
/// still matches the ones already on screen.
|
||||||
pub font_size: f32,
|
pub font_size: f32,
|
||||||
@@ -64,7 +86,7 @@ pub struct PendingSpawn {
|
|||||||
pub enum PendingState {
|
pub enum PendingState {
|
||||||
Connecting,
|
Connecting,
|
||||||
/// The attempt failed, with the reason as the user should read it. A
|
/// 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.
|
/// offers the next move rather than collapsing the split under the user.
|
||||||
Failed(SharedString),
|
Failed(SharedString),
|
||||||
}
|
}
|
||||||
@@ -167,7 +189,7 @@ impl Render for PendingPane {
|
|||||||
.text_color(theme.foreground)
|
.text_color(theme.foreground)
|
||||||
.child(format!("Couldn't reach {}", self.machine)),
|
.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 daemon isn't running", "that machine refused us" and
|
||||||
// "the server over there is too old" it was, because they want
|
// "the server over there is too old" it was, because they want
|
||||||
// completely different things from the user.
|
// completely different things from the user.
|
||||||
|
|||||||
+76
-14
@@ -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
|
//! 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
|
//! 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
|
//! ## 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
|
//! 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
|
//! host and its `ProxyCommand` already set up. There is deliberately no host
|
||||||
//! *editor* here; [`available_hosts`] only reads, and [`spec_for`] hands the
|
//! *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::core::session::{RemoteTarget, WorkspaceId};
|
||||||
use crate::daemon::control::{ControlHello, ControlRequest, ReplyOk};
|
use crate::daemon::control::{ControlHello, ControlRequest, ReplyOk};
|
||||||
use crate::daemon::install::{
|
use crate::daemon::install::{
|
||||||
InstallConfirm, InstallDecision, InstallRequest, MismatchedRemoteDaemon,
|
InstallConfirm, InstallDecision, InstallPhase, InstallProgress, InstallRequest,
|
||||||
|
MismatchedRemoteDaemon,
|
||||||
};
|
};
|
||||||
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, NativeSshSpec};
|
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, NativeSshSpec};
|
||||||
use crate::daemon::router::RouteHeader;
|
use crate::daemon::router::RouteHeader;
|
||||||
@@ -292,7 +293,7 @@ pub fn control_route(target: &RemoteTarget, cx: &App) -> Result<RouteHeader, Str
|
|||||||
/// A machine that answered: its host object, and the workspaces it says it has.
|
/// A machine that answered: its host object, and the workspaces it says it has.
|
||||||
pub struct Connected {
|
pub struct Connected {
|
||||||
pub host: Arc<RemoteHost>,
|
pub host: Arc<RemoteHost>,
|
||||||
/// 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.
|
/// remote's, never this client's.
|
||||||
pub home: PathBuf,
|
pub home: PathBuf,
|
||||||
pub rows: Vec<RemoteWorkspaceRow>,
|
pub rows: Vec<RemoteWorkspaceRow>,
|
||||||
@@ -409,7 +410,7 @@ pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-off session token. Design §10's takeover (M6) decides between two
|
/// A one-off session token. The takeover (M6) decides between two
|
||||||
/// clients by this plus the hostname; until then it is only carried.
|
/// clients by this plus the hostname; until then it is only carried.
|
||||||
fn new_session_token() -> String {
|
fn new_session_token() -> String {
|
||||||
uuid::Uuid::new_v4().to_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`,
|
/// 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<PathBuf> {
|
pub fn home(cx: &mut App, id: HostId) -> Option<PathBuf> {
|
||||||
cx.default_global::<RemoteConnections>()
|
cx.default_global::<RemoteConnections>()
|
||||||
.homes
|
.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.
|
/// remote's `workspaces.json` is the authority). Blocking.
|
||||||
pub fn put_remote_layout(host: &Arc<RemoteHost>, key: String, record: Value) -> io::Result<()> {
|
pub fn put_remote_layout(host: &Arc<RemoteHost>, key: String, record: Value) -> io::Result<()> {
|
||||||
host.client()
|
host.client()
|
||||||
@@ -572,7 +573,7 @@ pub fn put_remote_layout(host: &Arc<RemoteHost>, key: String, record: Value) ->
|
|||||||
/// Pull one workspace's authoritative record from the machine that owns it.
|
/// Pull one workspace's authoritative record from the machine that owns it.
|
||||||
/// Blocking.
|
/// 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)
|
/// [`ControlEvent::WorkspaceChanged`](crate::daemon::control::ControlEvent)
|
||||||
/// asks for: the event says only *that* a record moved, so the record itself is
|
/// 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
|
/// fetched rather than carried. `ErrorKind::NotFound` is a real answer — the
|
||||||
@@ -599,7 +600,7 @@ pub fn delete_remote_workspace(host: &Arc<RemoteHost>, 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.
|
/// 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
|
/// Bytes as the user thinks of them. Binary units with one decimal, matching the
|
||||||
/// download sizes shown elsewhere in the app.
|
/// 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;
|
const KIB: f64 = 1024.0;
|
||||||
let n = n as f64;
|
let n = n as f64;
|
||||||
if n < KIB {
|
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<Vec<(HostId, InstallPhase)>> = 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<InstallPhase> {
|
||||||
|
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
|
/// Install the GUI's consent handler. Called once at startup; without it the
|
||||||
/// process-wide default declines every install, which is deliberate — a tty7
|
/// 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
|
/// with no UI attached must not decide on the user's behalf that writing to
|
||||||
/// their servers is fine.
|
/// their servers is fine.
|
||||||
pub fn register(cx: &mut App) {
|
pub fn register(cx: &mut App) {
|
||||||
crate::daemon::install::set_install_confirm(Arc::new(GuiInstallConfirm));
|
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));
|
crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth));
|
||||||
// Touch the globals so the first connect isn't also the first allocation of
|
// 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.
|
// 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
|
/// **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
|
/// 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
|
/// `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
|
/// thread-local set by [`connect_blocking`], which held for the workspace
|
||||||
/// connect and quietly did not for a pane's — `connect_routed` lives in
|
/// connect and quietly did not for a pane's — `connect_routed` lives in
|
||||||
@@ -884,7 +946,7 @@ pub fn take_pending_auth() -> Option<PendingAuth> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 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.
|
/// 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<RemoteTarget> {
|
|||||||
origin_target(&m.host)
|
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
|
/// 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
|
/// **every pane that server hosts dies** — only ever call this with the user's
|
||||||
/// explicit answer behind it.
|
/// 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
|
/// *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
|
/// would turn an informed decision back into a blind one, so every one of
|
||||||
/// them is pinned here rather than eyeballed.
|
/// them is pinned here rather than eyeballed.
|
||||||
|
|||||||
+568
-70
File diff suppressed because it is too large
Load Diff
+10
-7
@@ -90,7 +90,7 @@ pub(crate) struct RightPanelState {
|
|||||||
/// runs, or `None` when the pane on screen has nothing to forward over.
|
/// 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
|
/// 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
|
/// say which of the two owners to ask, and the reschedule below re-reads
|
||||||
/// this rather than carrying the decision forward.
|
/// this rather than carrying the decision forward.
|
||||||
pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>,
|
pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>,
|
||||||
@@ -594,17 +594,20 @@ impl Tty7App {
|
|||||||
rows.push(("cwd", compact_path(&cwd)));
|
rows.push(("cwd", compact_path(&cwd)));
|
||||||
cwd_for_actions = Some(cwd);
|
cwd_for_actions = Some(cwd);
|
||||||
}
|
}
|
||||||
let shell = view.shell_spec().map(|s| s.program.clone());
|
// A pane that named no shell took its machine's default — which
|
||||||
rows.push((
|
// for a remote workspace is the *far* machine's, not this
|
||||||
"shell",
|
// computer's `$SHELL`.
|
||||||
crate::core::shells::default_shell_name(shell.as_deref()),
|
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() {
|
if let Some(ssh) = view.ssh_spec() {
|
||||||
rows.push(("ssh", ssh.host.clone()));
|
rows.push(("ssh", ssh.host.clone()));
|
||||||
}
|
}
|
||||||
// Two ways a pane has something to forward over: it *is* an
|
// Two ways a pane has something to forward over: it *is* an
|
||||||
// SSH session, or it belongs to a remote workspace, whose
|
// 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
|
// The second arm is empty in this build — nothing binds a pane
|
||||||
// to a workspace yet — which is deliberate: the band stays
|
// to a workspace yet — which is deliberate: the band stays
|
||||||
// empty rather than offering an add that would have nowhere to
|
// empty rather than offering an add that would have nowhere to
|
||||||
|
|||||||
+2
-2
@@ -572,7 +572,7 @@ pub(crate) enum AgentHooksView {
|
|||||||
/// [`crate::core::agent_hooks::HookAgent::ALL`] order.
|
/// [`crate::core::agent_hooks::HookAgent::ALL`] order.
|
||||||
Ready(Vec<AgentHookRow>),
|
Ready(Vec<AgentHookRow>),
|
||||||
/// The machine can't be acted on, and the sentence says which hop gave up
|
/// 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),
|
Unavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4279,7 +4279,7 @@ impl Tty7App {
|
|||||||
)
|
)
|
||||||
.into_any_element();
|
.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.
|
// next, rather than rows that would silently write nowhere.
|
||||||
AgentHooksView::Unavailable(reason) => {
|
AgentHooksView::Unavailable(reason) => {
|
||||||
return page
|
return page
|
||||||
|
|||||||
+4
-4
@@ -73,7 +73,7 @@ pub(crate) enum SftpEdit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Which SSH connection this panel's requests run on — the *only* thing that
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// looking at is the only thing that decides it.
|
||||||
pub(crate) open_pane_id: Option<u64>,
|
pub(crate) open_pane_id: Option<u64>,
|
||||||
/// The remote workspace the open pane belongs to, when it is one (design
|
/// The remote workspace the open pane belongs to, when it is one.
|
||||||
/// §15). Captured beside `open_pane_id` because every SFTP call needs it and
|
/// 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
|
/// 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.
|
/// reach. `None` — the case for SSH panes — keeps the pane-addressed path.
|
||||||
pub(crate) open_workspace: Option<crate::terminal::PaneWorkspace>,
|
pub(crate) open_workspace: Option<crate::terminal::PaneWorkspace>,
|
||||||
@@ -417,7 +417,7 @@ impl Tty7App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How this panel's requests reach the far side: the open pane's own
|
/// 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
|
/// Resolved on the UI thread and cloned into every background call, because
|
||||||
/// the pane entity is not reachable from a background executor.
|
/// the pane entity is not reachable from a background executor.
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ impl Tty7App {
|
|||||||
|
|
||||||
fn dismiss_and_advance(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn dismiss_and_advance(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let pane = self.ssh_prompt.pane.clone();
|
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
|
// 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
|
// has to happen on every exit from a routed sheet — answered, cancelled
|
||||||
// or dismissed.
|
// or dismissed.
|
||||||
|
|||||||
+213
-64
@@ -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 tty7_core::core::session::{RemoteTarget, WorkspaceId};
|
||||||
|
|
||||||
use crate::core::session::WorkspaceStore;
|
use crate::core::session::WorkspaceStore;
|
||||||
|
use crate::daemon::install::InstallPhase;
|
||||||
use crate::terminal::pane_liveness::Liveness;
|
use crate::terminal::pane_liveness::Liveness;
|
||||||
use crate::ui::app::Tty7App;
|
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;
|
use crate::ui::remote_workspace::ConnectFlow;
|
||||||
|
|
||||||
/// Card width — the command palette's, to the pixel.
|
/// Card width — the command palette's, to the pixel.
|
||||||
@@ -115,6 +116,11 @@ const ROW_PAD: f32 = 8.0;
|
|||||||
/// is what 52px bought.)
|
/// is what 52px bought.)
|
||||||
const WHEN_W: f32 = 96.0;
|
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.
|
/// What a machine's connection is doing, as far as this panel is concerned.
|
||||||
///
|
///
|
||||||
/// Deliberately coarser than
|
/// Deliberately coarser than
|
||||||
@@ -131,7 +137,7 @@ enum Link {
|
|||||||
/// A connect is in flight right now.
|
/// A connect is in flight right now.
|
||||||
Connecting,
|
Connecting,
|
||||||
/// The last attempt failed. A resting state, not a transient one: design
|
/// 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`]).
|
/// reason rides along on the group (see [`Group::error`]).
|
||||||
Failed,
|
Failed,
|
||||||
/// No connection. **Not an error** — the rows below it are what this client
|
/// 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
|
/// Why the last connect failed, shown under the header until the user acts
|
||||||
/// on it.
|
/// on it.
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
/// 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<InstallPhase>,
|
||||||
rows: Vec<Row>,
|
rows: Vec<Row>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +334,7 @@ impl Tty7App {
|
|||||||
link: Link::Offline,
|
link: Link::Offline,
|
||||||
home: None,
|
home: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
installing: None,
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
});
|
});
|
||||||
groups.len() - 1
|
groups.len() - 1
|
||||||
@@ -351,7 +364,7 @@ impl Tty7App {
|
|||||||
// looks like nothing happened: the connect runs, succeeds, and has
|
// looks like nothing happened: the connect runs, succeeds, and has
|
||||||
// nowhere to land — groups came only from workspaces this client had
|
// 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
|
// 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() {
|
for target in self.pending_machines() {
|
||||||
let key = target.to_string();
|
let key = target.to_string();
|
||||||
if index.contains_key(&key) {
|
if index.contains_key(&key) {
|
||||||
@@ -366,6 +379,7 @@ impl Tty7App {
|
|||||||
link: Link::Offline,
|
link: Link::Offline,
|
||||||
home: None,
|
home: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
installing: None,
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -385,6 +399,7 @@ impl Tty7App {
|
|||||||
link: Link::Offline,
|
link: Link::Offline,
|
||||||
home: None,
|
home: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
installing: None,
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -425,6 +440,12 @@ impl Tty7App {
|
|||||||
group.error = Some(error.clone());
|
group.error = Some(error.clone());
|
||||||
}
|
}
|
||||||
let id = target.host_id();
|
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
|
// Read app-wide, not from this window's snapshot: any window's
|
||||||
// connect, and every reconnect, records the machine's `$HOME` — and
|
// 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
|
// that row is the only way to make a workspace on a machine, so it
|
||||||
@@ -578,6 +599,29 @@ impl Tty7App {
|
|||||||
cx.notify();
|
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<Self>) {
|
||||||
|
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
|
/// Make a workspace on a machine. Local goes through the ordinary
|
||||||
/// `NewWorkspace` path; a remote one lands in *that machine's* `$HOME`.
|
/// `NewWorkspace` path; a remote one lands in *that machine's* `$HOME`.
|
||||||
fn switcher_new(&mut self, group: &GroupRef, window: &mut Window, cx: &mut Context<Self>) {
|
fn switcher_new(&mut self, group: &GroupRef, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -717,7 +761,7 @@ impl Tty7App {
|
|||||||
/// The one row the panel keeps below the fold — adding a machine it does not
|
/// 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.
|
/// 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,
|
/// 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
|
/// so this points at where that lives instead of growing a second place to
|
||||||
/// do it.
|
/// do it.
|
||||||
@@ -819,7 +863,10 @@ impl Tty7App {
|
|||||||
|
|
||||||
let mut block = v_flex().gap(px(1.));
|
let mut block = v_flex().gap(px(1.));
|
||||||
block = block.child(self.render_group_header(group, expanded, cx));
|
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
|
// 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.
|
// panel and leaving the user to guess between VPN, keys and the box.
|
||||||
if let Some(error) = group.error.as_ref() {
|
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.));
|
let mut kids = v_flex().gap(px(1.));
|
||||||
for row in rows {
|
for row in rows {
|
||||||
kids = kids.child(self.render_row(group, row, cx));
|
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));
|
block = block.child(self.indent(group, kids, cx));
|
||||||
}
|
}
|
||||||
Some(block.into_any_element())
|
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<Self>,
|
||||||
|
) -> 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,
|
/// 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.
|
/// tied to it by a guide line descending from that machine's icon.
|
||||||
///
|
///
|
||||||
@@ -904,8 +1018,8 @@ impl Tty7App {
|
|||||||
.top(px(0.))
|
.top(px(0.))
|
||||||
// Stops short of the last row's baseline rather than
|
// Stops short of the last row's baseline rather than
|
||||||
// running to the edge: a line that ends level with the
|
// running to the edge: a line that ends level with the
|
||||||
// final "New Workspace" glyph reads as enclosing the
|
// final row's glyph reads as enclosing the block, one
|
||||||
// block, one that runs past it reads as unfinished.
|
// that runs past it reads as unfinished.
|
||||||
.bottom(px(ROW_H / 2.))
|
.bottom(px(ROW_H / 2.))
|
||||||
.w(px(1.))
|
.w(px(1.))
|
||||||
.bg(rail),
|
.bg(rail),
|
||||||
@@ -928,6 +1042,10 @@ impl Tty7App {
|
|||||||
);
|
);
|
||||||
let hover = hover_fill(cx);
|
let hover = hover_fill(cx);
|
||||||
let gref = GroupRef::of(group);
|
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
|
// 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
|
// the row that says "somewhere else" before a word of it is read. Both
|
||||||
@@ -945,6 +1063,12 @@ impl Tty7App {
|
|||||||
let (dot, word): (Option<gpui::Hsla>, Option<&'static str>) = match group.link {
|
let (dot, word): (Option<gpui::Hsla>, Option<&'static str>) = match group.link {
|
||||||
Link::Local => (None, None),
|
Link::Local => (None, None),
|
||||||
Link::Connected => (Some(gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into()), 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::Connecting => (Some(theme.warning), Some("connecting…")),
|
||||||
Link::Failed => (Some(theme.danger), Some("couldn't connect")),
|
Link::Failed => (Some(theme.danger), Some("couldn't connect")),
|
||||||
Link::Offline => (
|
Link::Offline => (
|
||||||
@@ -1022,6 +1146,33 @@ impl Tty7App {
|
|||||||
.child(format!("{}", group.rows.len())),
|
.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(
|
.child(
|
||||||
Icon::new(if expanded {
|
Icon::new(if expanded {
|
||||||
IconName::ChevronDown
|
IconName::ChevronDown
|
||||||
@@ -1034,6 +1185,8 @@ impl Tty7App {
|
|||||||
.on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| {
|
.on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| {
|
||||||
this.switcher_toggle_host(&gref, 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<Self>) -> AnyElement {
|
fn render_row(&self, group: &Group, row: &Row, cx: &mut Context<Self>) -> AnyElement {
|
||||||
@@ -1181,53 +1334,6 @@ impl Tty7App {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_new_row(&self, group: &Group, cx: &mut Context<Self>) -> 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.
|
/// The machines with nothing on them yet, folded into one row.
|
||||||
fn render_other_hosts(
|
fn render_other_hosts(
|
||||||
&self,
|
&self,
|
||||||
@@ -1355,7 +1461,7 @@ impl Group {
|
|||||||
/// it names that this client has no record of becomes an extra row marked
|
/// 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
|
/// for adoption. Rows this client *does* have are left alone: their local
|
||||||
/// record carries window geometry and the `open` flag, which are this
|
/// 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) {
|
fn merge(&mut self, remote: &[RemoteWorkspaceRow], now: u64) {
|
||||||
if self.target.is_none() {
|
if self.target.is_none() {
|
||||||
return;
|
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<Tty7App>,
|
||||||
|
) -> 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.
|
/// A row's second-tier actions.
|
||||||
///
|
///
|
||||||
/// One `⋯` rather than a cluster of glyphs, and the destructive one behind it
|
/// 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
|
/// 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
|
/// 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`];
|
/// 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
|
/// the rows underneath use the narrower [`ROW_AVATAR`], so their monograms
|
||||||
/// above it rather than beside them.
|
/// share one axis of their own rather than sitting 3px off the machines'.
|
||||||
fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement {
|
fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.w(px(w))
|
.w(px(w))
|
||||||
|
|||||||
@@ -1057,7 +1057,7 @@ impl Tty7App {
|
|||||||
// path, and that is correct rather than a shortcut: a tab
|
// path, and that is correct rather than a shortcut: a tab
|
||||||
// belongs to one workspace, a workspace names one machine in
|
// belongs to one workspace, a workspace names one machine in
|
||||||
// `Workspace.host`, and a window shows one workspace — design
|
// `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)`
|
// the qualified key is `(workspace.host_id(), sidebar_group)`
|
||||||
// with the host half held once per workspace instead of once
|
// with the host half held once per workspace instead of once
|
||||||
// per tab, and two machines can't collide here without a
|
// per tab, and two machines can't collide here without a
|
||||||
|
|||||||
+7
-12
@@ -21,7 +21,7 @@ use crate::core::actions::{
|
|||||||
SelectWorkspace5, SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9,
|
SelectWorkspace5, SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9,
|
||||||
TogglePalette,
|
TogglePalette,
|
||||||
};
|
};
|
||||||
use crate::core::config::{Config, RightPanelTab};
|
use crate::core::config::RightPanelTab;
|
||||||
use crate::daemon::protocol::ShellSpec;
|
use crate::daemon::protocol::ShellSpec;
|
||||||
use crate::ui::app::{TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, Tab, Tty7App, tile_trailing_inset};
|
use crate::ui::app::{TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, Tab, Tty7App, tile_trailing_inset};
|
||||||
use crate::ui::hints::tab_badge_label;
|
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
|
/// Attach the "new tab" shell picker to a button: the default shell leads
|
||||||
/// shell leads the menu (tagged `default`), followed by every shell detected
|
/// the menu (tagged `default`), followed by every shell found on **this
|
||||||
/// on this machine; clicking one opens a tab on that shell. Extracted so the
|
/// window's machine**; clicking one opens a tab on that shell. Extracted so
|
||||||
/// title-bar strip's "+" and the vertical [`tab_sidebar`] share one menu
|
/// the title-bar strip's "+" and the vertical [`tab_sidebar`] share one menu
|
||||||
/// definition rather than duplicating the shell iteration.
|
/// definition rather than duplicating the shell iteration.
|
||||||
///
|
///
|
||||||
/// [`tab_sidebar`]: crate::ui::tab_sidebar
|
/// [`tab_sidebar`]: crate::ui::tab_sidebar
|
||||||
@@ -894,13 +894,8 @@ impl Tty7App {
|
|||||||
button: Button,
|
button: Button,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> impl IntoElement + use<> {
|
) -> impl IntoElement + use<> {
|
||||||
let shells = self.detected_shells.clone();
|
let shells = self.shells.shells.clone();
|
||||||
let default_name = crate::core::shells::default_shell_name(
|
let default_name = self.default_shell_label(cx);
|
||||||
cx.global::<Config>()
|
|
||||||
.shell
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.program.as_str()),
|
|
||||||
);
|
|
||||||
let app = cx.entity().downgrade();
|
let app = cx.entity().downgrade();
|
||||||
button.dropdown_menu(move |menu, _window, _cx| {
|
button.dropdown_menu(move |menu, _window, _cx| {
|
||||||
let mut menu = menu.min_w(px(220.));
|
let mut menu = menu.min_w(px(220.));
|
||||||
|
|||||||
+68
-3
@@ -457,6 +457,21 @@ fn confirm_destructive(
|
|||||||
/// Callers confirm first unless [`live_pane_count`] answered zero; with nothing
|
/// Callers confirm first unless [`live_pane_count`] answered zero; with nothing
|
||||||
/// running there is nothing to lose.
|
/// running there is nothing to lose.
|
||||||
pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) {
|
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
|
// 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
|
// *that* daemon's. Sending them here would not fail — it would succeed
|
||||||
// against whatever local panes happen to hold those numbers, killing a
|
// 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)
|
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
|
// *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.
|
// window closes, because the route to the daemon is read off a live pane.
|
||||||
if let Some(app) = WindowRegistry::app_for(cx, workspace)
|
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.
|
// half-finished action.
|
||||||
close_window_for(cx, workspace);
|
close_window_for(cx, workspace);
|
||||||
WorkspaceStore::close_window(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);
|
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
|
/// Delete a workspace outright: stop it, then forget it entirely. Irreversible
|
||||||
/// — nothing about the layout survives.
|
/// — nothing about the layout survives.
|
||||||
pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) {
|
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
|
// still on file. Doing this after `WorkspaceStore::remove` would leave the
|
||||||
// record stranded on the remote with no way left to name it.
|
// record stranded on the remote with no way left to name it.
|
||||||
delete_on_remote(cx, workspace);
|
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);
|
WorkspaceStore::remove(cx, workspace);
|
||||||
release_unused_hosts(cx);
|
release_unused_hosts(cx);
|
||||||
refresh_menu(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
|
/// 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).
|
/// pointer would leave the workspace there and reappear on the next connect).
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user