mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.
- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
static against musl and pushed onto the remote box. One dependency, on
purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
makes goes through the machine it belongs to. `LocalHost` answers on this
box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
of them run on the background executor with the result landed on the UI
thread; de-duplication, staleness and error reporting live here rather than
at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
workspace list → a window bound to one workspace on it. Workspace switcher
groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
cargo-zigbuild, a host-boundary grep, and version stamping factored out of
the nightly workflow. Both new jobs are non-required so branch protection
does not wedge open PRs.
Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
This commit is contained in:
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Usage: assert-static.sh <path-to-elf>
|
||||
# Fail unless the binary is a fully static ELF — no dynamic loader, no shared
|
||||
# library dependencies.
|
||||
#
|
||||
# This is the mechanical guard behind D10 (docs/2026-07-27-remote-workspace-design.md):
|
||||
# one `tty7-server` binary is pushed to arbitrary remote machines and must run
|
||||
# there regardless of what libc, and what *version* of it, that machine has. A
|
||||
# build that silently picked up a dynamic dependency would still pass a
|
||||
# compile-only CI job and then fail on the first old box a user connects to —
|
||||
# far from the change that caused it. Cheap to assert, expensive to discover.
|
||||
set -euo pipefail
|
||||
|
||||
BIN="$1"
|
||||
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::assert-static.sh: $BIN does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- file ---"
|
||||
file "$BIN"
|
||||
echo "--- readelf -d ---"
|
||||
readelf -d "$BIN" || true
|
||||
|
||||
fail=0
|
||||
|
||||
# `file` says "statically linked" for a classic static binary and "static-pie
|
||||
# linked" for a position-independent one. Rust's musl targets have shipped both
|
||||
# shapes depending on toolchain version, and both are equally self-contained, so
|
||||
# accept either — but nothing else.
|
||||
if ! file "$BIN" | grep -Eq 'statically linked|static-pie linked'; then
|
||||
echo "::error::$BIN is not statically linked (D10 requires a self-contained binary)"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# The decisive check: a static binary has no PT_INTERP segment, i.e. no
|
||||
# request for /lib/ld-musl-*.so or ld-linux-*.so. This catches the case `file`
|
||||
# alone would not, where a dynamic loader is still required.
|
||||
if readelf -l "$BIN" | grep -q 'Requesting program interpreter'; then
|
||||
echo "::error::$BIN requires a dynamic loader (PT_INTERP present) — not a static build"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# Belt and braces: no DT_NEEDED entries, i.e. no shared libraries to resolve.
|
||||
if readelf -d "$BIN" 2>/dev/null | grep -q 'NEEDED'; then
|
||||
echo "::error::$BIN has shared-library dependencies (DT_NEEDED) — not a static build"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ $BIN is a static binary ($(du -h "$BIN" | cut -f1))"
|
||||
@@ -13,7 +13,14 @@ set -euo pipefail
|
||||
|
||||
TARGET="$1"
|
||||
ARCH="$2"
|
||||
VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
# Anchored on `= "` — see the note in bundle-macos.sh: the root manifest leads
|
||||
# with `version.workspace = true`, which a bare `^version` match would return
|
||||
# verbatim as the "version" and bake into every asset filename.
|
||||
VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "bundle-linux: could not read a version from Cargo.toml (got '$VERSION')" >&2
|
||||
exit 1
|
||||
fi
|
||||
NAME="tty7-${VERSION}-linux-${ARCH}"
|
||||
STAGE="dist/${NAME}"
|
||||
|
||||
|
||||
@@ -12,7 +12,15 @@ set -euo pipefail
|
||||
|
||||
TARGET="$1"
|
||||
ARCH="$2"
|
||||
VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
# Anchored on `= "` because the root manifest's `[package]` section leads with
|
||||
# `version.workspace = true` — a bare `^version` match grabs that line, finds no
|
||||
# quotes to substitute, and passes it through as the "version", which then lands
|
||||
# in CFBundleVersion and the .dmg filename. Guard against a silent recurrence.
|
||||
VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "bundle-macos: could not read a version from Cargo.toml (got '$VERSION')" >&2
|
||||
exit 1
|
||||
fi
|
||||
APP="dist/tty7.app"
|
||||
|
||||
rm -rf dist
|
||||
|
||||
@@ -26,6 +26,23 @@ Copy-Item "assets/completions/*.json" "$Stage/completions/"
|
||||
Copy-Item LICENSE "$Stage/LICENSE.txt"
|
||||
Copy-Item README.md "$Stage/README.md"
|
||||
|
||||
# The Linux musl `tty7-server`, staged at server/ so a WSL distro can be handed
|
||||
# the binary this client shipped with (design §12: WSL downloads nothing). The
|
||||
# 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
|
||||
# change on its own. Missing is a warning, not an error, matching `server-musl`'s
|
||||
# own skip-don't-fail probe; WSL then fails at connect time with a message
|
||||
# naming the directories it searched.
|
||||
$ServerAsset = "tty7-server-x86_64-unknown-linux-musl"
|
||||
$ServerSrc = "bundled-server/$ServerAsset"
|
||||
if (Test-Path $ServerSrc) {
|
||||
New-Item -ItemType Directory -Force -Path "$Stage/server" | Out-Null
|
||||
Copy-Item $ServerSrc "$Stage/server/$ServerAsset"
|
||||
Write-Host "OK bundled $ServerAsset"
|
||||
} else {
|
||||
Write-Warning "no $ServerAsset to bundle - this build cannot serve WSL distros"
|
||||
}
|
||||
|
||||
Compress-Archive -Path "$Stage/*" -DestinationPath "dist/$Name.zip" -Force
|
||||
|
||||
# Installer, built from the same staged payload. ISCC is on PATH on GitHub's
|
||||
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Contract §10.6 — the GUI must not touch the filesystem or git directly.
|
||||
#
|
||||
# Once a workspace can live on a remote machine, a path held by `ui::` or
|
||||
# `terminal::` is not necessarily a path on *this* box, and `std::path`'s
|
||||
# fs-backed APIs quietly answer for the wrong machine (contract §4.3):
|
||||
# `canonicalize` walks the local filesystem, `is_absolute` says `false` for
|
||||
# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may
|
||||
# be looking at a workspace path has to go through `ui::host_ops` / the `Host`
|
||||
# trait, which routes to the local disk or the far side as appropriate.
|
||||
#
|
||||
# This script enforces that. It is deliberately *not* the raw grep from the
|
||||
# contract: run bare, that grep reports 42 hits on a clean tree — not one of them
|
||||
# git, all of them modules whose paths are local by construction — and a guard
|
||||
# that always fails is a guard everyone learns to ignore. Two things fix it:
|
||||
#
|
||||
# 1. Test bodies are cut off properly. The contract's `grep -v '#\[cfg(test)\]'`
|
||||
# only removes the attribute line itself, leaving the whole test module
|
||||
# behind it in scope; here the scan stops at the `#[cfg(test)] mod …` that
|
||||
# opens the trailing test region.
|
||||
# 2. An explicit allowlist, keyed on (file, pattern) rather than whole files, so
|
||||
# a module exempted for its `.is_absolute()` still trips on a new
|
||||
# `std::fs::`. Every entry carries the reason its paths cannot be remote.
|
||||
#
|
||||
# Adding an entry is a deliberate act: if the path could ever be a workspace
|
||||
# path, the answer is `Host`, not an allowlist line.
|
||||
#
|
||||
# Usage: bash .github/scripts/check-host-boundary.sh
|
||||
# Exit 0 = clean, 1 = violations (printed to stderr).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
ROOTS=(src/ui src/terminal)
|
||||
|
||||
# The forbidden constructs, as extended-regex alternatives.
|
||||
PATTERN='std::fs::|Command::new\("git"\)|\.canonicalize\(\)|\.is_absolute\(\)'
|
||||
|
||||
# Allowlist entries are `<path>|<literal pattern>`, one per line. `<path>|*`
|
||||
# exempts a file wholesale (used only for the boundary module itself). The
|
||||
# pattern side is matched as a plain substring of the offending line.
|
||||
ALLOW=$(
|
||||
cat <<'EOF'
|
||||
# The host boundary itself: every routed filesystem call lands here by design.
|
||||
src/ui/host_ops.rs|*
|
||||
|
||||
# Themes and presets are app-owned files under the local config dir
|
||||
# (`themes_dir()`), never a workspace path — a remote workspace does not carry
|
||||
# the user's color schemes with it.
|
||||
src/ui/presets.rs|std::fs::
|
||||
src/ui/presets.rs|.is_absolute()
|
||||
src/ui/app.rs|std::fs::create_dir_all
|
||||
|
||||
# Reading a private key off *this* machine to hash it into a keychain account
|
||||
# (`core::keychain`). The key is the client's credential; the far side never
|
||||
# sees the file, only the resulting auth.
|
||||
src/ui/ssh_prompt.rs|std::fs::read
|
||||
src/ui/ssh_connect.rs|std::fs::read
|
||||
|
||||
# Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs
|
||||
# this app's own history search. A remote pane's history is the remote shell's
|
||||
# business, read over the wire, not through here.
|
||||
src/terminal/history.rs|std::fs::
|
||||
|
||||
# Completion is already remote-aware: a remote pane is signalled by `cwd: None`,
|
||||
# which disables exactly these local-filesystem candidate sources in favour of
|
||||
# `remote_path_request` / `remote_path_candidates`. The `$PATH` scan is likewise
|
||||
# this machine's `$PATH`, deliberately withheld from remote panes.
|
||||
src/terminal/completion.rs|std::fs::read_dir
|
||||
src/terminal/completion.rs|.is_absolute()
|
||||
|
||||
# Bundled completion specs shipped in `assets/completions`, resolved from the
|
||||
# app bundle / dev manifest dir. Program data, not user or workspace data.
|
||||
src/terminal/signature.rs|std::fs::read_to_string
|
||||
|
||||
# Opening a path scraped out of terminal output resolves it against the local
|
||||
# cwd — a local-pane affordance; `resolve_existing_path` declines when there is
|
||||
# no local cwd.
|
||||
src/terminal/search.rs|.is_absolute()
|
||||
|
||||
# Clipboard-image paste stages the bytes into the OS temp dir so an agent TUI
|
||||
# can be handed a path. Always `std::env::temp_dir()` on this machine.
|
||||
src/terminal/view.rs|std::fs::create_dir_all
|
||||
src/terminal/view.rs|std::fs::write
|
||||
EOF
|
||||
)
|
||||
|
||||
# Where the trailing `#[cfg(test)] mod …` starts, if any. Line numbers are
|
||||
# preserved because only the tail is dropped.
|
||||
body_of() {
|
||||
local file=$1 cut
|
||||
cut=$(awk '
|
||||
/^#\[cfg\(test\)\]$/ { attr = NR }
|
||||
/^mod [A-Za-z_]/ { if (attr == NR - 1) { print NR - 1; exit } }
|
||||
' "$file")
|
||||
if [ -n "$cut" ]; then
|
||||
head -n "$((cut - 1))" "$file"
|
||||
else
|
||||
cat "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
allowed() {
|
||||
local file=$1 line=$2 entry path pat
|
||||
while IFS= read -r entry; do
|
||||
case "$entry" in '' | '#'*) continue ;; esac
|
||||
path=${entry%%|*}
|
||||
pat=${entry#*|}
|
||||
[ "$path" = "$file" ] || continue
|
||||
if [ "$pat" = '*' ] || [ "${line#*"$pat"}" != "$line" ]; then
|
||||
return 0
|
||||
fi
|
||||
done <<<"$ALLOW"
|
||||
return 1
|
||||
}
|
||||
|
||||
# A guard that scans nothing reports success, which is the one failure mode worse
|
||||
# than a noisy guard. Fail loudly if a root has moved out from under us.
|
||||
for root in "${ROOTS[@]}"; do
|
||||
if [ ! -d "$root" ]; then
|
||||
echo "check-host-boundary: scan root '$root' does not exist (moved? renamed?)" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
violations=0
|
||||
scanned=0
|
||||
while IFS= read -r file; do
|
||||
scanned=$((scanned + 1))
|
||||
while IFS= read -r hit; do
|
||||
lineno=${hit%%:*}
|
||||
text=${hit#*:}
|
||||
# Prose, not code.
|
||||
case "$(printf '%s' "$text" | sed 's/^[[:space:]]*//')" in '//'*) continue ;; esac
|
||||
if allowed "$file" "$text"; then
|
||||
continue
|
||||
fi
|
||||
printf '%s:%s:%s\n' "$file" "$lineno" "$text" >&2
|
||||
violations=$((violations + 1))
|
||||
done < <(body_of "$file" | grep -nE "$PATTERN" || true)
|
||||
done < <(find "${ROOTS[@]}" -name '*.rs' | sort)
|
||||
|
||||
if [ "$scanned" -lt 10 ]; then
|
||||
echo "check-host-boundary: only $scanned files scanned — the roots look wrong" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$violations" -ne 0 ]; then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Contract §10.6: the GUI reached the filesystem/git directly.
|
||||
|
||||
A path in `ui::` or `terminal::` may belong to a remote workspace, where these
|
||||
calls answer for the wrong machine. Route it through `ui::host_ops` / `Host`
|
||||
instead (`Host::read_dir`, `join`, `is_absolute`, `canonicalize`, …).
|
||||
|
||||
If the path genuinely cannot be remote — app config, bundled assets, the local
|
||||
temp dir — add it to the allowlist in this script with the reason why.
|
||||
--------------------------------------------------------------------------------
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "host boundary clean: $scanned files in ${ROOTS[*]}, no direct fs/git calls"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Usage: stamp-version.sh <version>
|
||||
# Rewrite the package version in Cargo.toml. Used by the nightly workflow, where
|
||||
# every versioned artifact — the binary's CARGO_PKG_VERSION, asset names, the DMG
|
||||
# plist, the Inno installer — reads Cargo.toml, so this one edit covers them all.
|
||||
# Cargo.lock is left alone: cargo refreshes the root package's own lock entry
|
||||
# automatically, without network access.
|
||||
#
|
||||
# Extracted from the inline step so nightly's several build jobs stamp
|
||||
# identically, and so the guard below has exactly one home.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="$1"
|
||||
|
||||
awk -v ver="$VERSION" '!done && /^version = /{ $0 = "version = \"" ver "\""; done = 1 } { print }' \
|
||||
Cargo.toml > Cargo.toml.tmp
|
||||
mv Cargo.toml.tmp Cargo.toml
|
||||
|
||||
# Fail loudly if the substitution missed. Without this the awk is a silent no-op
|
||||
# whenever Cargo.toml's shape changes — e.g. if the root manifest ever becomes a
|
||||
# virtual workspace whose members carry `version.workspace = true`, in which case
|
||||
# the version to stamp moves to `[workspace.package]`. A nightly that quietly
|
||||
# publishes assets named after the *last stable* version is far worse than one
|
||||
# that fails here.
|
||||
if ! grep -qx "version = \"$VERSION\"" Cargo.toml; then
|
||||
echo "::error::stamp-version.sh did not stamp $VERSION into Cargo.toml — the manifest's version line is not where this script expects it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Report the line actually stamped, not the first thing that looks like a
|
||||
# version: post-crate-split the root package carries `version.workspace = true`
|
||||
# and the real value lives further down under [workspace.package], so a naive
|
||||
# `grep -m1 '^version'` prints the inherit marker and tells you nothing.
|
||||
grep -n "^version = \"$VERSION\"" Cargo.toml
|
||||
@@ -57,6 +57,10 @@ Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs
|
||||
Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; The Linux musl tty7-server, for serving WSL distros without a download.
|
||||
; `skipifsourcedoesntexist` because a build whose server-musl leg was skipped
|
||||
; still has to produce an installer. See bundle-windows.ps1.
|
||||
Source: "{#StageDir}\server\*"; DestDir: "{app}\server"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\tty7"; Filename: "{app}\tty7.exe"
|
||||
|
||||
@@ -22,6 +22,24 @@ jobs:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --check
|
||||
|
||||
# Contract §10.6: `ui::` and `terminal::` must not reach the filesystem or git
|
||||
# directly, because once a workspace can be remote those calls answer for the
|
||||
# wrong machine (§4.3). The allowlist of genuinely-local paths lives in the
|
||||
# script, next to the reason each one is exempt.
|
||||
#
|
||||
# A standalone job on purpose, and one that must stay *non-required*: main's
|
||||
# required checks are `rustfmt` and the three `build & test (<target>)` names,
|
||||
# and folding this into either would make it required the moment it lands —
|
||||
# wedging every open PR on a check they have never seen. Same reasoning as
|
||||
# `server-musl` below. Cheap enough (a checkout and a grep) that it does not
|
||||
# need caching or a toolchain.
|
||||
host-boundary:
|
||||
name: host boundary (§10.6)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash .github/scripts/check-host-boundary.sh
|
||||
|
||||
build:
|
||||
name: build & test (${{ matrix.target }})
|
||||
strategy:
|
||||
@@ -74,3 +92,94 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: cargo test --locked --target ${{ matrix.target }}
|
||||
|
||||
# Static musl builds of the headless server binary that remote workspaces push
|
||||
# onto the far machine (docs/2026-07-27-remote-workspace-design.md, D10/§12).
|
||||
# One binary has to run on any distro without regard to the target's glibc
|
||||
# version, so it is linked fully static against musl rather than built per
|
||||
# distro. Compile-only — this job publishes nothing; release.yml and
|
||||
# nightly.yml carry the same job with an upload step.
|
||||
#
|
||||
# Deliberately a *separate* job rather than two more rows in the `build` matrix
|
||||
# above: those three `build & test (<target>)` names are main's required
|
||||
# checks, and reshaping that matrix would wedge branch protection on every open
|
||||
# PR. Keep this job non-required until it has a few weeks of green — see
|
||||
# docs/remote-server-assets.md.
|
||||
server-musl:
|
||||
name: tty7-server musl (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- x86_64-unknown-linux-musl
|
||||
- aarch64-unknown-linux-musl
|
||||
# `-C strip=symbols` is applied by rustc through the linker, so it strips the
|
||||
# aarch64 output from an x86_64 runner — plain `strip(1)` would not. Set here
|
||||
# rather than in [profile.release] because the release profile is shared with
|
||||
# the GUI builds, which keep their symbols.
|
||||
env:
|
||||
RUSTFLAGS: -C strip=symbols
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# zig supplies both the musl sysroot and the C cross-compiler, which is
|
||||
# what makes one x86_64 runner able to emit both musl targets. russh's
|
||||
# default crypto backend (aws-lc-rs) builds a sizable C/asm library through
|
||||
# cmake, and that is the part every other approach trips over: `cross`
|
||||
# needs a custom image to get cmake into the sandbox, and Ubuntu's
|
||||
# `musl-tools` only ships an x86_64 `musl-gcc` wrapper with no C++ driver
|
||||
# and nothing at all for aarch64. Verified locally: both targets link
|
||||
# statically and the resulting binaries run under Alpine.
|
||||
#
|
||||
# If setup-zig ever becomes a problem (its GitHub repo is a mirror of a
|
||||
# Codeberg original), cargo-zigbuild also accepts zig from PyPI —
|
||||
# `pip3 install ziglang`, which it finds via CARGO_ZIGBUILD_PYTHON_PATH —
|
||||
# so this can drop to zero third-party actions without changing anything
|
||||
# else.
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
# The crate split (§11) lands separately; until `tty7-server` exists as a
|
||||
# workspace member this job has nothing to build. Skip cleanly rather than
|
||||
# fail, so the workflow can land before the split and simply start working
|
||||
# once it arrives. `--no-deps` keeps this to a manifest parse — no
|
||||
# resolution, no network.
|
||||
- name: Look for the tty7-server package
|
||||
id: probe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if cargo metadata --no-deps --format-version 1 \
|
||||
| jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::tty7-server is not a workspace member yet (crate split, design §11 / M1) — nothing to build"
|
||||
fi
|
||||
|
||||
# `-p tty7-server` addresses the package by name, so this survives whatever
|
||||
# directory layout the split settles on. It also keeps feature unification
|
||||
# scoped to the server's own dependency graph: the GUI crate is the one
|
||||
# that turns on tty7-core's `gssapi` feature, and that feature cannot build
|
||||
# under musl (libgssapi-sys wants a system MIT/Heimdal krb5). Building the
|
||||
# whole workspace here would drag it in and fail.
|
||||
- name: Build static tty7-server
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
run: cargo zigbuild --release --locked -p tty7-server --target ${{ matrix.target }}
|
||||
|
||||
- name: Assert the binary is static
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server"
|
||||
|
||||
+125
-12
@@ -52,7 +52,9 @@ jobs:
|
||||
|
||||
# Mirrors release.yml's build matrix; keep the two in sync when editing.
|
||||
build:
|
||||
needs: plan
|
||||
# Also behind server-musl: the Windows installer embeds its Linux musl
|
||||
# binary for WSL. See the same note in release.yml.
|
||||
needs: [plan, server-musl]
|
||||
if: needs.plan.outputs.build == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -82,19 +84,13 @@ jobs:
|
||||
path: tty7
|
||||
|
||||
# Everything versioned — the binary (CARGO_PKG_VERSION), asset names,
|
||||
# DMG plist, Inno installer — reads Cargo.toml, so stamping it here is
|
||||
# the only edit needed. Cargo.lock is left alone: cargo refreshes the
|
||||
# root package's own lock entry automatically, without network access.
|
||||
# DMG plist, Inno installer — reads Cargo.toml, so stamping it is the only
|
||||
# edit needed. Shared with the server-musl job below, and it fails loudly
|
||||
# if the manifest's shape ever moves the version line out from under it.
|
||||
- name: Stamp nightly version
|
||||
working-directory: tty7
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
run: |
|
||||
awk -v ver="$VERSION" '!done && /^version = /{ $0 = "version = \"" ver "\""; done = 1 } { print }' \
|
||||
Cargo.toml > Cargo.toml.tmp
|
||||
mv Cargo.toml.tmp Cargo.toml
|
||||
grep -m1 '^version' Cargo.toml
|
||||
run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}"
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: matrix.os == 'linux'
|
||||
@@ -141,6 +137,15 @@ jobs:
|
||||
working-directory: tty7
|
||||
run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}"
|
||||
|
||||
# See release.yml for why this is best-effort rather than required.
|
||||
- name: Fetch the bundled Linux server
|
||||
if: matrix.os == 'windows'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: nightly-server-x86_64-unknown-linux-musl
|
||||
path: tty7/bundled-server
|
||||
|
||||
- name: Package Windows installer + zip
|
||||
if: matrix.os == 'windows'
|
||||
working-directory: tty7
|
||||
@@ -161,11 +166,100 @@ jobs:
|
||||
tty7/dist/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
# Mirrors release.yml's server-musl job; keep the two in sync when editing.
|
||||
# Nightly carries the server binaries too so the remote-install path (§12) can
|
||||
# be exercised against the rolling channel instead of waiting for a tag.
|
||||
server-musl:
|
||||
needs: plan
|
||||
if: needs.plan.outputs.build == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- x86_64-unknown-linux-musl
|
||||
- aarch64-unknown-linux-musl
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: -C strip=symbols
|
||||
steps:
|
||||
- name: Checkout tty7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: tty7
|
||||
|
||||
# Stamped for the same reason the GUI builds are: the server reports
|
||||
# CARGO_PKG_VERSION over the wire during the version handshake, and a
|
||||
# nightly server claiming the last stable version would make that
|
||||
# negotiation lie. The asset *name* is version-free either way.
|
||||
- name: Stamp nightly version
|
||||
working-directory: tty7
|
||||
run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: tty7
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Look for the tty7-server package
|
||||
id: probe
|
||||
working-directory: tty7
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if cargo metadata --no-deps --format-version 1 \
|
||||
| jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::tty7-server is not a workspace member yet — tonight's nightly carries no remote-server assets"
|
||||
fi
|
||||
|
||||
# No `--locked` here, matching the rest of nightly: the version stamp above
|
||||
# rewrites Cargo.toml, and cargo has to be free to refresh the root
|
||||
# package's own lock entry.
|
||||
- name: Build static tty7-server
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
working-directory: tty7
|
||||
run: cargo zigbuild --release -p tty7-server --target ${{ matrix.target }}
|
||||
|
||||
- name: Assert the binary is static
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
working-directory: tty7
|
||||
run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server"
|
||||
|
||||
- name: Stage the asset
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
working-directory: tty7
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
cp "target/${{ matrix.target }}/release/tty7-server" \
|
||||
"dist/tty7-server-${{ matrix.target }}"
|
||||
chmod +x "dist/tty7-server-${{ matrix.target }}"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
with:
|
||||
name: nightly-server-${{ matrix.target }}
|
||||
path: tty7/dist/tty7-server-${{ matrix.target }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Single publish step after all platforms succeed, so the rolling release is
|
||||
# always complete — a failed platform means tonight's nightly is skipped
|
||||
# entirely and users keep yesterday's, never a partial asset set.
|
||||
publish:
|
||||
needs: [plan, build]
|
||||
needs: [plan, build, server-musl]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -178,6 +272,25 @@ jobs:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
# Same contract as release.yml — see docs/remote-server-assets.md. Written
|
||||
# into dist/ before the upload below so it ships as an asset like any
|
||||
# other, and so the prune step at the end sees it as current.
|
||||
- name: Generate checksums.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
rm -f checksums.txt
|
||||
# Built in $RUNNER_TEMP and moved in: a redirect straight into dist/
|
||||
# creates the file before find walks the directory, so it would hash
|
||||
# itself as a zero-byte entry. `xargs -r` so an empty dist/ fails
|
||||
# instead of hanging on stdin.
|
||||
find . -type f -printf '%P\n' \
|
||||
| LC_ALL=C sort | xargs -r sha256sum > "$RUNNER_TEMP/checksums.txt"
|
||||
[ -s "$RUNNER_TEMP/checksums.txt" ] || { echo "::error::no assets to checksum"; exit 1; }
|
||||
mv "$RUNNER_TEMP/checksums.txt" checksums.txt
|
||||
sha256sum -c checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
- name: Update rolling nightly release
|
||||
run: |
|
||||
# Move the tag first so the release object follows it to this SHA.
|
||||
|
||||
@@ -10,6 +10,14 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# The Windows installer embeds the Linux musl `tty7-server` so a WSL distro
|
||||
# can be served the binary the client already shipped with, instead of
|
||||
# downloading one (design §12: WSL installs nothing over the network). That
|
||||
# binary comes from `server-musl`, so the two jobs can no longer run in
|
||||
# parallel. Serialising all four platforms behind it costs a few minutes on
|
||||
# a release — cheap next to splitting the Windows entry into its own job and
|
||||
# duplicating the whole toolchain/caching preamble.
|
||||
needs: server-musl
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -103,6 +111,19 @@ jobs:
|
||||
working-directory: tty7
|
||||
run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}"
|
||||
|
||||
# The bundled server for WSL. `continue-on-error` mirrors `server-musl`'s
|
||||
# own probe step: if there is no server asset, the release still ships and
|
||||
# `bundle-windows.ps1` warns. It is not silent at runtime either — a WSL
|
||||
# connect then fails with `MissingBundled`, naming every directory it
|
||||
# searched, rather than quietly falling back to a download.
|
||||
- name: Fetch the bundled Linux server
|
||||
if: matrix.os == 'windows'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: release-server-x86_64-unknown-linux-musl
|
||||
path: tty7/bundled-server
|
||||
|
||||
- name: Package Windows installer + zip
|
||||
if: matrix.os == 'windows'
|
||||
working-directory: tty7
|
||||
@@ -128,6 +149,100 @@ jobs:
|
||||
tty7/dist/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
# The headless server binary remote workspaces install on the far machine
|
||||
# (design doc D10/§12). Statically linked against musl so a single binary runs
|
||||
# on any distro whatever its glibc vintage, and shipped as a bare executable
|
||||
# rather than an archive so the client can fetch exactly one file and verify it
|
||||
# against checksums.txt. Asset naming contract: docs/remote-server-assets.md.
|
||||
#
|
||||
# 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.
|
||||
server-musl:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- x86_64-unknown-linux-musl
|
||||
- aarch64-unknown-linux-musl
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: -C strip=symbols
|
||||
steps:
|
||||
- name: Checkout tty7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: tty7
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# zig provides the musl sysroot and the C cross-compiler for both targets
|
||||
# from one x86_64 runner — see the same job in ci.yml for why the
|
||||
# alternatives (cross, musl-tools) do not cope with aws-lc-rs' cmake build.
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: tty7
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
# Until the crate split (§11) lands there is no tty7-server to build. Skip
|
||||
# rather than fail, so this workflow can ship ahead of the split; the
|
||||
# release simply carries no server assets until it arrives.
|
||||
- name: Look for the tty7-server package
|
||||
id: probe
|
||||
working-directory: tty7
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if cargo metadata --no-deps --format-version 1 \
|
||||
| jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::tty7-server is not a workspace member yet — this release will carry no remote-server assets"
|
||||
fi
|
||||
|
||||
# `--locked` for the same reason the GUI build uses it: a release ships the
|
||||
# dependency set the tag recorded. `-p tty7-server` both addresses the
|
||||
# package independently of its path and keeps feature unification off the
|
||||
# GUI's `gssapi` feature, which cannot build under musl.
|
||||
- name: Build static tty7-server
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
working-directory: tty7
|
||||
run: cargo zigbuild --release --locked -p tty7-server --target ${{ matrix.target }}
|
||||
|
||||
- name: Assert the binary is static
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
working-directory: tty7
|
||||
run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server"
|
||||
|
||||
# Flat, version-free asset name — the tag in the download URL carries the
|
||||
# version. See docs/remote-server-assets.md for the contract the client
|
||||
# installer derives this name from.
|
||||
- name: Stage the asset
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
working-directory: tty7
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
cp "target/${{ matrix.target }}/release/tty7-server" \
|
||||
"dist/tty7-server-${{ matrix.target }}"
|
||||
chmod +x "dist/tty7-server-${{ matrix.target }}"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
with:
|
||||
name: release-server-${{ matrix.target }}
|
||||
path: tty7/dist/tty7-server-${{ matrix.target }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Single assembly step, after all four platforms succeed. The release object is
|
||||
# created as a **draft** and left that way: a draft is invisible to both
|
||||
# /releases/latest and the releases page, so nothing can prompt a user to
|
||||
@@ -135,7 +250,7 @@ jobs:
|
||||
# empty. Publishing is the release skill's job — it verifies the six assets and
|
||||
# writes the body first, then flips the draft. See .claude/skills/release/SKILL.md.
|
||||
draft-release:
|
||||
needs: build
|
||||
needs: [build, server-musl]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -146,6 +261,35 @@ jobs:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
# sha256 over every asset, so the remote-server installer can verify what
|
||||
# it downloaded before writing it to someone else's machine (design §16 —
|
||||
# a mismatch aborts the install outright). Generated here rather than in
|
||||
# the build jobs because only this job sees the complete asset set, and a
|
||||
# per-job fragment would have to be concatenated in a deterministic order
|
||||
# anyway. GNU coreutils format ("<hex> <name>"), bare filenames, sorted —
|
||||
# see docs/remote-server-assets.md for the format the client parses.
|
||||
- name: Generate checksums.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
rm -f checksums.txt
|
||||
# `find -type f` rather than a glob: nested files (should any appear)
|
||||
# would otherwise be silently skipped, leaving an asset unverifiable.
|
||||
#
|
||||
# Built in $RUNNER_TEMP and moved in, rather than redirected straight
|
||||
# into dist/: the `>` redirect creates its target *before* find walks
|
||||
# the directory, so a file written in place would end up hashing
|
||||
# itself as a zero-byte entry — a line that can never verify.
|
||||
#
|
||||
# `xargs -r` — without it an empty dist/ would leave sha256sum reading
|
||||
# stdin and the job would hang rather than fail.
|
||||
find . -type f -printf '%P\n' \
|
||||
| LC_ALL=C sort | xargs -r sha256sum > "$RUNNER_TEMP/checksums.txt"
|
||||
[ -s "$RUNNER_TEMP/checksums.txt" ] || { echo "::error::no assets to checksum"; exit 1; }
|
||||
mv "$RUNNER_TEMP/checksums.txt" checksums.txt
|
||||
sha256sum -c checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
# Reuse an existing release rather than failing: re-triggering a tag
|
||||
# (force-push after a fixed platform) must top up the same draft. If the
|
||||
# release was already published, --clobber just replaces its assets and it
|
||||
|
||||
@@ -27,3 +27,6 @@ repomix-output.*
|
||||
# benchmark harness work dir + generated fixtures (scripts/bench/)
|
||||
.bench/
|
||||
/big.log
|
||||
|
||||
# Lavish review artifacts (design surfaces rendered for a single review pass)
|
||||
/.lavish/
|
||||
|
||||
Generated
+81
-10
@@ -2022,7 +2022,7 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9547,43 +9547,71 @@ dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"core-foundation 0.10.0",
|
||||
"getrandom 0.3.4",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"gpui-component-assets",
|
||||
"gpui_platform",
|
||||
"ignore",
|
||||
"image",
|
||||
"jieba-rs",
|
||||
"keyring",
|
||||
"ksni",
|
||||
"libc",
|
||||
"libgssapi",
|
||||
"log",
|
||||
"memchr",
|
||||
"notify 8.2.0",
|
||||
"notify-rust",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"plist",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest_client",
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"tray-icon",
|
||||
"tty7-core",
|
||||
"uuid",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tty7-core"
|
||||
version = "26.7.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"core-foundation 0.10.0",
|
||||
"getrandom 0.3.4",
|
||||
"ignore",
|
||||
"libc",
|
||||
"libgssapi",
|
||||
"log",
|
||||
"memchr",
|
||||
"notify 8.2.0",
|
||||
"portable-pty",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tray-icon",
|
||||
"ureq",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tty7-server"
|
||||
version = "26.7.5"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tty7-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9721,6 +9749,34 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -9766,6 +9822,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -10198,6 +10260,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
||||
+47
-87
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tty7"
|
||||
version = "26.7.5"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
description = "A terminal workbench: shells, persistent sessions, SSH, coding agents — GPU-rendered on Zed's gpui, pure Rust"
|
||||
repository = "https://github.com/l0ng-ai/tty7"
|
||||
@@ -14,6 +14,21 @@ name = "tty7"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The framework-free half of tty7: wire protocol, session daemon, PTY, the
|
||||
# native SSH engine, and the domain model the headless `tty7-server` shares with
|
||||
# this GUI. Everything that does *not* need gpui lives there — see
|
||||
# `docs/2026-07-27-remote-workspace-design.md` §11.
|
||||
# `gssapi` is off in tty7-core's defaults (a static musl `tty7-server` cannot
|
||||
# link the system krb5 it binds); the GUI, which builds against a real desktop
|
||||
# toolchain, turns it on so managed SSH connections keep offering
|
||||
# `gssapi-with-mic` exactly as before.
|
||||
#
|
||||
# `remote-install` is off there for the same shape of reason: it pulls an HTTPS
|
||||
# client used only to *download* a `tty7-server` onto a remote machine
|
||||
# (design §12, D5). The server binary is the thing being downloaded, so it can
|
||||
# never take that path; the GUI, which is the client that pushes it, can.
|
||||
tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] }
|
||||
|
||||
gpui = { workspace = true }
|
||||
gpui_platform = { workspace = true }
|
||||
gpui-component = { workspace = true }
|
||||
@@ -29,16 +44,23 @@ smallvec.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
|
||||
# SSH connection-manager data layer (`core::ssh_profile` / `core::keychain`).
|
||||
# `uuid` mints stable profile ids (v4) and serde-serializes them as strings;
|
||||
# `sha2` hashes private-key file contents to the sha512-hex account key that
|
||||
# passphrases are stored under in the OS keychain (per PRD §7.2). Both are already
|
||||
# in the tree transitively, so pinning them here adds no new native code. `keyring`
|
||||
# 4.x is the OS credential-vault backend: its default `v1` feature auto-selects the
|
||||
# platform store (macOS Keychain / Windows Credential Manager / Linux Secret
|
||||
# Service), so no per-platform feature guards are needed.
|
||||
# SSH profile ids in the connection manager UI (`ui::ssh_connect`,
|
||||
# `ui::settings`, the command palette). The profiles themselves — and the
|
||||
# secret-free `CredentialRef` they carry — live in `tty7-core`, which `Config`
|
||||
# needs to parse `config.json` without gpui.
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
# The credential vault's storage half (`core::keychain`). `keyring` 4.x's default
|
||||
# `v1` feature auto-selects the platform store (macOS Keychain / Windows
|
||||
# Credential Manager / Linux Secret Service), so no per-platform guards are
|
||||
# needed.
|
||||
#
|
||||
# It sits here rather than in `tty7-core` on purpose: every caller is in `ui::`
|
||||
# (`ssh_prompt`, `ssh_connect`, `settings`, `app`), and leaving it downstairs made
|
||||
# the headless `tty7-server` — a static binary pushed onto machines that have no
|
||||
# keychain at all — link `zbus`/`secret-service` and thirty-odd crates behind them
|
||||
# for code it can never reach. Only the secret-free naming half (`CredentialRef`,
|
||||
# the account scheme) stays in core, where `config.json` parsing needs it.
|
||||
keyring = "4"
|
||||
|
||||
# Theme files. tty7 themes are authored as YAML (`~/.config/tty7/themes/*.yaml`,
|
||||
@@ -99,57 +121,6 @@ notify-rust = "4"
|
||||
# editor, `ui::code`) reuses it to refresh the tree and detect external edits.
|
||||
notify = "8"
|
||||
|
||||
# Code panel (file tree + editor, `ui::code`): `ignore` supplies the gitignore
|
||||
# matcher chain the file tree uses to dim ignored entries (same crate ripgrep
|
||||
# uses). The editor itself needs nothing else — text storage and syntax
|
||||
# highlighting both live inside gpui-component's `InputState`.
|
||||
ignore = "0.4"
|
||||
|
||||
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, behind
|
||||
# one blocking `Read`/`Write`/`resize` API. This is what lets `daemon::pane` share
|
||||
# a single code path across platforms instead of hand-rolling fd/ioctl/signal code.
|
||||
portable-pty = "0.8"
|
||||
|
||||
# Native (pure-Rust) SSH client for the daemon's russh session engine
|
||||
# (`daemon::ssh`). Replaces shelling out to the system `ssh` binary for managed
|
||||
# connections: it gives us the protocol stack that GUI-hosted auth, host-key
|
||||
# prompts, in-memory connection reuse, and (later) SFTP / native port-forwarding
|
||||
# all need. 0.62 folded the old `russh-keys` crate in as `russh::keys` (key
|
||||
# parsing + ssh-agent client). The async russh session lives on a small tokio
|
||||
# runtime owned by `daemon::ssh`; the rest of the daemon stays std-threads and
|
||||
# bridges to it through blocking `Read`/`Write` adapters.
|
||||
russh = "0.62"
|
||||
|
||||
# SFTP client for the native SSH engine (`daemon::ssh::sftp`, Workstream 5).
|
||||
# Not part of russh proper: `russh-sftp` drives the SFTP subsystem over any
|
||||
# AsyncRead+AsyncWrite stream, which a russh session channel provides via
|
||||
# `Channel::into_stream()`. Version-independent of russh (it only needs the
|
||||
# channel byte stream), so it rides the same tokio runtime `daemon::ssh` owns.
|
||||
russh-sftp = "2"
|
||||
|
||||
# tokio powers only the russh session engine — a single runtime `daemon::ssh`
|
||||
# owns. The daemon's PTY/reader/writer threads remain std threads and never touch
|
||||
# it; they cross into async through bounded/unbounded channels (the blocking
|
||||
# `Read`/`Write` adapters in `daemon::ssh::session`).
|
||||
tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"net",
|
||||
"io-util",
|
||||
"sync",
|
||||
"time",
|
||||
"macros",
|
||||
"process",
|
||||
# `fs` powers the local side of SFTP transfers (`daemon::ssh::sftp`): async
|
||||
# file/dir IO on the daemon process's own filesystem during upload/download.
|
||||
"fs",
|
||||
] }
|
||||
|
||||
# SIMD byte search for the OSC tokenizer's Ground/Ignore fast paths — the
|
||||
# sniffers sit on the full-throughput output stream (100+ MB/s at full drain),
|
||||
# where a per-byte state machine costs a measurable slice of the reader loop.
|
||||
# Already in the tree transitively (vte et al.), so this pins no new code.
|
||||
memchr = "2"
|
||||
|
||||
# System tray / menu bar status item (`ui::tray`). Rasterizes the bundled SVG
|
||||
# logo into the tray bitmap at runtime — gpui's own SVG path only yields a
|
||||
# tinted alpha mask, not raw RGBA. Pinned to the exact version already in the
|
||||
@@ -165,30 +136,11 @@ resvg = { version = "0.45", default-features = false }
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
tray-icon = "0.24"
|
||||
|
||||
# `setsid` (daemon detach) and the macOS foreground-process proc queries are the
|
||||
# only libc users left, both Unix-only — so the dep is Unix-only too.
|
||||
# The pty-size ioctl in `terminal::generator` is the only libc user left in the
|
||||
# GUI (the daemon's own libc calls went to tty7-core), and it is Unix-only — so
|
||||
# the dep is Unix-only too.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
libgssapi = "0.11"
|
||||
|
||||
# The Windows GUI⇄daemon transport is loopback TCP, which (unlike a Unix socket)
|
||||
# any local process can connect to — so the daemon authenticates each connection
|
||||
# against a random token it writes into the user-private port file. `getrandom`
|
||||
# is the OS CSPRNG that mints that token; already in the tree transitively, so
|
||||
# this pins no new code. Windows-only, matching the transport it guards.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
getrandom = "0.3"
|
||||
|
||||
# Toolhelp process enumeration + `TerminateProcess`, used by `daemon::winproc` to
|
||||
# title a pane by its foreground command and to tear down a shell's descendant
|
||||
# tree on hangup (ConPTY's `kill` only reaches the shell itself). Already in the
|
||||
# tree via gpui's Windows backend, so this pins no new code. Windows-only.
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
# Embeds `assets/favicon.ico` into the `.exe` so Windows shows the tty7 logo in
|
||||
# the taskbar / window / Explorer (macOS gets its icon from the `.app` bundle via
|
||||
@@ -237,9 +189,6 @@ ksni = { version = "0.3.6", default-features = false, features = ["blocking", "a
|
||||
# into test builds and never reaches a release binary.
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
# `test-util` unlocks tokio's paused clock (`start_paused`) so the ssh prompt
|
||||
# broker's timeout/retry tests run instantly instead of in real time. Dev-only.
|
||||
tokio = { version = "1", features = ["test-util", "macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -247,10 +196,21 @@ workspace = true
|
||||
# ---- Standalone workspace mirroring gpui-component's pins so the git/source
|
||||
# ---- caches are shared and versions stay aligned. ----
|
||||
[workspace]
|
||||
members = ["."]
|
||||
members = ["crates/*"]
|
||||
# The root `tty7` package is a member implicitly; naming all three here is what
|
||||
# makes a bare `cargo build` / `cargo test` at the root cover the whole
|
||||
# workspace, which is how CI invokes them.
|
||||
default-members = [".", "crates/tty7-core", "crates/tty7-server"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
# The single version for all three crates — `tty7`, `tty7-core`, `tty7-server`
|
||||
# all inherit it with `version.workspace = true`. They ship together and a
|
||||
# client talking to a server of a different build has to be able to say so, so
|
||||
# they must never drift apart. This is the line a release bump edits (and the
|
||||
# one nightly's `awk '/^version = /'` stamps: it is the first such line in the
|
||||
# file, since the package entries above are all `version.workspace = true`).
|
||||
version = "26.7.5"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Our fork's `tty7` branch carries the local customizations tty7 relies on:
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
[package]
|
||||
name = "tty7-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "tty7's framework-free core: wire protocol, session daemon, PTY, SSH engine, and the domain model both the GUI and the headless server build on"
|
||||
repository = "https://github.com/l0ng-ai/tty7"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
# The whole point of this crate is that it does *not* depend on gpui. Everything
|
||||
# here has to compile and run on a headless Linux box (that is what
|
||||
# `tty7-server` is), so nothing windowing-, rendering- or GUI-shaped belongs in
|
||||
# these dependencies — see `docs/2026-07-27-remote-workspace-design.md` §11.
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
|
||||
# SSH connection-manager data layer (`core::ssh_profile` / `core::keychain`) and
|
||||
# workspace identity (`core::session`). `uuid` mints stable ids (v4) and
|
||||
# serde-serializes them as strings.
|
||||
#
|
||||
# These live here rather than in the GUI because `Config` embeds
|
||||
# `Vec<SshProfile>` and `config.json` has to parse identically on the server.
|
||||
#
|
||||
# Deliberately *absent*: `keyring`. Nothing in this crate reads or writes a
|
||||
# secret — the daemon gets them pre-resolved on the wire (`NativeSshSpec`) — and
|
||||
# a headless `tty7-server` has no OS keychain to read from, so the vault's
|
||||
# storage half lives in the GUI crate instead (`tty7::core::keychain`). Keeping
|
||||
# it out here is what stops a static server binary from linking
|
||||
# `zbus`/`secret-service` and 30-odd crates behind them for code it can never
|
||||
# call. What stays is the *naming* half: `CredentialRef` and the account scheme,
|
||||
# which `config.json` parsing needs.
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Two unrelated hashes, both server-side: `daemon::install::checksums` verifies a
|
||||
# downloaded `tty7-server` asset against the release's sha256 manifest, and
|
||||
# `core::keychain::key_account_from_contents` derives the sha512-hex account key
|
||||
# a private key's passphrase is stored under (PRD §7.2) — the account *name* is
|
||||
# part of the persisted config contract, so it belongs next to `CredentialRef`
|
||||
# even though only the GUI computes one today.
|
||||
sha2 = "0.10"
|
||||
|
||||
# The gitignore matcher chain (`core::gitignore`) the file tree dims entries
|
||||
# with — the same crate ripgrep uses. Lives here rather than in the GUI because
|
||||
# the remote server has to answer "is this path ignored?" with the identical
|
||||
# implementation.
|
||||
ignore = "0.4"
|
||||
|
||||
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows,
|
||||
# behind one blocking `Read`/`Write`/`resize` API. This is what lets
|
||||
# `daemon::pane` share a single code path across platforms instead of
|
||||
# hand-rolling fd/ioctl/signal code.
|
||||
portable-pty = "0.8"
|
||||
|
||||
# Native (pure-Rust) SSH client for the daemon's russh session engine
|
||||
# (`daemon::ssh`) — see the root manifest's note for why we own this stack
|
||||
# instead of shelling out to `ssh`.
|
||||
# HTTPS client for the remote-server installer (`daemon::install::download`).
|
||||
# The GUI's own update check rides `reqwest_client`, which wraps Zed's reqwest
|
||||
# fork behind `gpui::http_client` — unavailable here, since this crate must not
|
||||
# depend on gpui. `ureq` is blocking (matching the installer, which runs on a
|
||||
# daemon std thread), rustls-based (no OpenSSL to find at build time), and
|
||||
# reuses the `rustls`/`http` versions already in the tree. `gzip` is off: the
|
||||
# assets are already-compressed binaries.
|
||||
#
|
||||
# Optional, and off by default, so the static musl `tty7-server` never links a
|
||||
# TLS stack for a code path it cannot take — it *is* the binary being
|
||||
# downloaded. The GUI package turns the feature on (see the root `Cargo.toml`).
|
||||
ureq = { version = "3", default-features = false, features = [
|
||||
"rustls",
|
||||
], optional = true }
|
||||
|
||||
russh = "0.62"
|
||||
russh-sftp = "2"
|
||||
|
||||
# tokio powers only the russh session engine — a single runtime `daemon::ssh`
|
||||
# owns. The daemon's PTY/reader/writer threads remain std threads and never
|
||||
# touch it; they cross into async through bounded/unbounded channels.
|
||||
tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"net",
|
||||
"io-util",
|
||||
"sync",
|
||||
"time",
|
||||
"macros",
|
||||
"process",
|
||||
"fs",
|
||||
] }
|
||||
|
||||
# Filesystem watching for `host::local` (`Host::watch`): one non-recursive
|
||||
# watcher per expanded directory, coalesced into 100ms batches. Same crate and
|
||||
# version the GUI already used for the file tree, so the server watches a remote
|
||||
# tree exactly the way the client watched a local one.
|
||||
notify = "8"
|
||||
|
||||
# `smol::channel` carries watch batches out of the coalescing thread. Only the
|
||||
# channel is used — the executor stays in the GUI — but taking it from `smol`
|
||||
# rather than `async-channel` directly keeps the `Receiver` type identical to the
|
||||
# one gpui code already awaits.
|
||||
smol.workspace = true
|
||||
|
||||
# SIMD byte search for the OSC tokenizer's Ground/Ignore fast paths — the
|
||||
# sniffers sit on the full-throughput output stream (100+ MB/s at full drain),
|
||||
# where a per-byte state machine costs a measurable slice of the reader loop.
|
||||
memchr = "2"
|
||||
|
||||
# Base64 for the byte fields that cross the control dialect's JSON wire
|
||||
# (`host::Output`'s stdout/stderr). JSON has no byte type — `serde_json` renders
|
||||
# a `Vec<u8>` as an array of decimal numbers, inflating a 1 MB `git diff` to
|
||||
# roughly 4 MB. Base64 costs 1.33× instead. Already in the tree via russh.
|
||||
base64 = "0.22"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
# GSSAPI/Kerberos SSH auth — see the `gssapi` feature below for why it is
|
||||
# optional rather than an unconditional Unix dependency.
|
||||
libgssapi = { version = "0.11", optional = true }
|
||||
|
||||
# CFStringTokenizer-adjacent CoreFoundation FFI: `daemon::pane` reads a pane's
|
||||
# foreground process name through it on macOS.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
|
||||
# The Windows GUI⇄daemon transport is loopback TCP, which (unlike a Unix socket)
|
||||
# any local process can connect to — so the daemon authenticates each connection
|
||||
# against a random token it writes into the user-private port file. `getrandom`
|
||||
# is the OS CSPRNG that mints that token.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
getrandom = "0.3"
|
||||
|
||||
# Toolhelp process enumeration + `TerminateProcess`, used by `daemon::winproc` to
|
||||
# title a pane by its foreground command and to tear down a shell's descendant
|
||||
# tree on hangup (ConPTY's `kill` only reaches the shell itself).
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Sandboxes for the `host::conformance` suite: a fresh empty directory per case,
|
||||
# removed on drop.
|
||||
tempfile = "3"
|
||||
|
||||
# `test-util` unlocks tokio's paused clock (`start_paused`) so the ssh prompt
|
||||
# broker's timeout/retry tests run instantly instead of in real time.
|
||||
tokio = { version = "1", features = ["test-util", "macros", "rt"] }
|
||||
|
||||
# `gssapi` — GSSAPI/Kerberos `gssapi-with-mic` SSH auth (`daemon::ssh::auth`).
|
||||
#
|
||||
# Off by default, and the `tty7` GUI turns it on, so the GUI's behavior is
|
||||
# unchanged. It has to be optional because `libgssapi` binds the *system* MIT /
|
||||
# Heimdal krb5 through bindgen: a machine without krb5 headers cannot build it
|
||||
# at all, and a static musl `tty7-server` — the binary remote workspaces push
|
||||
# onto arbitrary hosts (design §12, D10) — cannot link it under any
|
||||
# circumstances. Without the feature, `SshAuthMode::Gssapi` reports that the
|
||||
# method is unavailable in this build and the other auth families are untouched.
|
||||
#
|
||||
# **Test coverage.** Everything in `daemon::ssh::auth` that can be tested without
|
||||
# krb5 — the service-host list — is gated on `unix` alone, so a plain
|
||||
# `cargo test -p tty7-core` runs it. Only the libgssapi FFI half needs
|
||||
# `--features gssapi`, and it has no unit tests (it is all foreign calls). Do not
|
||||
# gate a testable helper on this feature: `cargo test --workspace` unifies it on
|
||||
# from the GUI package, so a feature-gated test looks green there and silently
|
||||
# vanishes the moment anyone narrows to `-p tty7-core`.
|
||||
[features]
|
||||
default = []
|
||||
gssapi = ["dep:libgssapi"]
|
||||
# Lets this build download a `tty7-server` release asset over HTTPS and push it
|
||||
# onto a remote machine (design §12). Off by default so `tty7-server` — which is
|
||||
# the thing being downloaded, and never the thing doing the downloading — links
|
||||
# no HTTP client. Without it, `daemon::install` still installs from bytes it is
|
||||
# handed and still launches/probes a remote daemon; only the fetch fails, with a
|
||||
# message saying so.
|
||||
remote-install = ["dep:ureq"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
//! Git, the way every part of tty7 reads it: one shell-out per field, always
|
||||
//! `git -C <cwd>`, always `GIT_OPTIONAL_LOCKS=0`.
|
||||
//!
|
||||
//! This is the shared bottom layer, not a feature: the sidebar's branch/diff
|
||||
//! line, the diff overlay, and (from the remote-workspace work on) the server
|
||||
//! side all go through the same [`git`] invocation, so a read tty7 performs can
|
||||
//! never take `index.lock` and fight a real git command the user is running.
|
||||
//! Deliberately shell-out simple — blocking, one process per question; callers
|
||||
//! run it on a background thread so nothing UI-facing waits on a slow repo.
|
||||
//!
|
||||
//! The caching layer that fans one probe out to every pane in a repo is *not*
|
||||
//! here: it is a gpui global, so it stays in the GUI crate
|
||||
//! (`terminal::git_status::GitStatusCache`).
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::host::{Host, Output};
|
||||
|
||||
/// A repo's git snapshot: the branch it's on and how much the working tree has
|
||||
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
|
||||
/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files
|
||||
/// and untracked files don't contribute a line count.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GitStatus {
|
||||
/// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD
|
||||
/// is detached. Never empty.
|
||||
pub branch: String,
|
||||
/// Lines added across the working tree vs `HEAD`.
|
||||
pub added: u32,
|
||||
/// Lines removed across the working tree vs `HEAD`.
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// One raw probe result, before it's folded into the cache: which work tree
|
||||
/// `cwd` belongs to, plus the fields probed there. `counts` is `None` when the
|
||||
/// `git diff` invocation itself failed (e.g. it raced a concurrent git write) —
|
||||
/// distinct from a clean tree's `Some((0, 0))`, so the cache can keep the
|
||||
/// previous numbers instead of pretending the tree went clean.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RepoSnapshot {
|
||||
/// The work tree root (`git rev-parse --show-toplevel`) — the cache key
|
||||
/// every pane inside this work tree shares. For a linked worktree this is
|
||||
/// the worktree's own directory, not the main checkout's.
|
||||
pub root: PathBuf,
|
||||
/// The *repository* the work tree belongs to: the main checkout's root
|
||||
/// when `root` is a linked worktree, otherwise `root` itself. The
|
||||
/// sidebar's grouping key — every worktree of one repo shares it, while
|
||||
/// branch/diff state stays per work tree under `root`.
|
||||
pub home: PathBuf,
|
||||
pub branch: String,
|
||||
pub counts: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Probe the git snapshot for `cwd` on `host`, or `None` when it isn't inside a
|
||||
/// git work tree (or the path is gone, or the host can't be reached).
|
||||
/// Blocking — the GUI calls it through `ui::host_ops`, never on the UI thread.
|
||||
///
|
||||
/// Three invocations, and deliberately not fewer. The first `rev-parse` answers
|
||||
/// every *path* question at once: the work-tree root (which doubles as the "is
|
||||
/// this a git repo" gate — it fails outside a work tree) plus the
|
||||
/// git-dir/common-dir pair that tells a linked worktree from a main checkout.
|
||||
/// Asking those separately cost two process spawns per probe, which mattered
|
||||
/// once probes stopped being rare: they now also fire on window activation and
|
||||
/// on an agent's tool calls, across every pane. The branch cannot join them —
|
||||
/// `symbolic-ref` is what names a branch before the first commit exists, and
|
||||
/// folding `--abbrev-ref HEAD` into the `rev-parse` would make the whole
|
||||
/// invocation fail on an unborn branch and lose the paths with it.
|
||||
///
|
||||
/// On a remote host each of the three is a round trip; the throttle and the
|
||||
/// in-flight dedup in the GUI's `GitStatusCache` are what keep that from being
|
||||
/// three per pane per trigger.
|
||||
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
|
||||
// No `exists` pre-check: a vanished cwd already fails `Host::git` with
|
||||
// `NotFound`, which lands as `None` here — the same answer, one round trip
|
||||
// cheaper.
|
||||
let paths = git(
|
||||
host,
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--show-toplevel",
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
)?;
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
// A git old enough to reject `--path-format` fails the whole invocation
|
||||
// above, so reaching here means the two dirs are present — but degrade to
|
||||
// "main checkout" rather than trusting that, same as the old code did.
|
||||
let home = repo_home(&root, lines.next(), lines.next());
|
||||
let branch = branch_name(host, cwd)?;
|
||||
Some(RepoSnapshot {
|
||||
home,
|
||||
root,
|
||||
branch,
|
||||
counts: diff_numstat(host, cwd),
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository "home" every checkout of one repo shares, from the work-tree
|
||||
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
|
||||
/// (its git dir differs from the common git dir) the main work tree's root —
|
||||
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
|
||||
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
|
||||
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
|
||||
/// the bare directory itself — still one shared key.
|
||||
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
|
||||
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
if git_dir == common {
|
||||
return root.to_path_buf();
|
||||
}
|
||||
let common = Path::new(common);
|
||||
match (common.file_name(), common.parent()) {
|
||||
(Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(),
|
||||
_ => common.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current branch name, or a short sha for a detached HEAD. Shared with
|
||||
/// `terminal::git_diff` in the GUI crate, which fronts its overlay with the
|
||||
/// same branch label the sidebar row shows.
|
||||
pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option<String> {
|
||||
// On a branch — even before the first commit — `symbolic-ref` names it.
|
||||
if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
|
||||
let name = out.trim();
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
// Detached HEAD (or a rebase/bisect): fall back to the short commit sha.
|
||||
let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?;
|
||||
let sha = sha.trim();
|
||||
(!sha.is_empty()).then(|| sha.to_string())
|
||||
}
|
||||
|
||||
/// Sum added/removed lines across the working tree vs `HEAD` from
|
||||
/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing.
|
||||
/// `None` when the invocation itself failed — the caller keeps old counts.
|
||||
fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> {
|
||||
let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?;
|
||||
let mut added = 0u32;
|
||||
let mut removed = 0u32;
|
||||
for line in out.lines() {
|
||||
let mut fields = line.split('\t');
|
||||
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
|
||||
added += n;
|
||||
}
|
||||
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
|
||||
removed += n;
|
||||
}
|
||||
}
|
||||
Some((added, removed))
|
||||
}
|
||||
|
||||
/// Run `git -C <cwd> <args>` on `host` and return stdout on success, `None` on
|
||||
/// a non-zero exit or a git that never ran.
|
||||
///
|
||||
/// The projection of [`git_output`] the snapshot readers want: they treat "git
|
||||
/// said no" and "git could not be asked" identically — a failed probe leaves
|
||||
/// the previous snapshot standing rather than blanking the branch line — where
|
||||
/// callers like `core::worktree` need the two kept apart, and the stderr with
|
||||
/// them. Non-UTF-8 stdout is `None`: there is nothing sensible to parse out of
|
||||
/// it.
|
||||
///
|
||||
/// Which machine's `git` runs is the host's business; the *invariants* of the
|
||||
/// invocation are [`git_output`]'s, and every implementation of
|
||||
/// [`Host::git`] owes them.
|
||||
pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let out = host.git(cwd, args).ok()?;
|
||||
if !out.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(out.stdout).ok()
|
||||
}
|
||||
|
||||
/// The full result of `git -C <cwd> <args>` — exit code, stdout *and* stderr —
|
||||
/// under the invariants every git invocation in tty7 shares.
|
||||
///
|
||||
/// This is the bottom layer [`git`] is a projection of, and the one
|
||||
/// [`crate::host::Host::git`] exposes: a `Host` has to answer for a remote
|
||||
/// machine's git too, where "non-zero exit" and "git never ran" are genuinely
|
||||
/// different outcomes and the caller needs stderr to say which. `Ok` means the
|
||||
/// process ran (the exit code is in [`Output::status`]); `Err` means it could
|
||||
/// not be run at all.
|
||||
///
|
||||
/// The invariants, all of them load-bearing:
|
||||
///
|
||||
/// - **`-C <cwd>`**, never `Command::current_dir` — the working directory of
|
||||
/// this process is not a thing a GUI with many panes can meaningfully set.
|
||||
/// - **`GIT_OPTIONAL_LOCKS=0`**, so a background read can never take
|
||||
/// `index.lock` and lose a race against a real git command the user is
|
||||
/// running. It only suppresses *optional* locks; writes still lock normally.
|
||||
/// - **stdin nulled**, so a misconfigured credential helper or a prompt-happy
|
||||
/// subcommand fails immediately instead of hanging a background thread
|
||||
/// forever on a terminal nobody is attached to.
|
||||
/// - **`GIT_DIR` / `GIT_WORK_TREE` removed**, so a tty7 launched from inside a
|
||||
/// git hook (or any shell that exported them) can't have that ambient
|
||||
/// repository silently override the `-C` we just passed.
|
||||
/// - **`hide_console` on Windows**, so probing a repo doesn't flash a console
|
||||
/// window.
|
||||
///
|
||||
/// A `cwd` that doesn't exist is [`io::ErrorKind::NotFound`] rather than git's
|
||||
/// own exit 128: "the directory is gone" is not a question about the repository,
|
||||
/// and callers (and the remote `Host` contract) distinguish the two.
|
||||
pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
if !cwd.exists() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("git cwd does not exist: {}", cwd.display()),
|
||||
));
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(cwd)
|
||||
.args(args)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let out = crate::core::proc::hide_console(&mut cmd).output()?;
|
||||
Ok(Output {
|
||||
status: out.status.code(),
|
||||
stdout: out.stdout,
|
||||
stderr: out.stderr,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The host these tests probe through: this machine, which is what the GUI
|
||||
/// hands `probe` for a local pane.
|
||||
fn h() -> crate::host::SharedHost {
|
||||
crate::host::local::LocalHost::new()
|
||||
}
|
||||
|
||||
/// A tmp path that is not a git repo yields no snapshot (and never panics).
|
||||
#[test]
|
||||
fn non_repo_is_none() {
|
||||
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
assert_eq!(probe(&*h(), &dir), None);
|
||||
}
|
||||
|
||||
/// A path that doesn't exist is `None`, not a panic.
|
||||
#[test]
|
||||
fn missing_path_is_none() {
|
||||
assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None);
|
||||
}
|
||||
|
||||
/// This repo (the crate root is inside the tty7 work tree) reports a branch
|
||||
/// and a root, exercising the real `git` probe end-to-end.
|
||||
#[test]
|
||||
fn own_repo_has_a_branch_and_root() {
|
||||
let here = env!("CARGO_MANIFEST_DIR");
|
||||
if let Some(snap) = probe(&*h(), Path::new(here)) {
|
||||
assert!(!snap.branch.is_empty());
|
||||
assert!(Path::new(here).starts_with(&snap.root));
|
||||
}
|
||||
// If the crate is built outside a work tree (e.g. a vendored tarball),
|
||||
// `None` is the correct answer and the assertions above are skipped.
|
||||
}
|
||||
/// The four shapes `repo_home` has to tell apart, straight from the
|
||||
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
|
||||
#[test]
|
||||
fn repo_home_resolves_worktree_layouts() {
|
||||
let root = Path::new("/repo/.wt/feat");
|
||||
|
||||
// A main checkout: the two dirs agree, so the work tree is its own home.
|
||||
assert_eq!(
|
||||
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A linked worktree: the common dir is the main checkout's `.git`, so
|
||||
// the home is that `.git`'s parent — the main work tree.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A bare repo with worktrees hanging off it: no `.git` component to
|
||||
// strip, so the bare dir itself is the shared key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
|
||||
PathBuf::from("/bare.git")
|
||||
);
|
||||
// A git too old (or too odd) to answer both: degrade to the work tree
|
||||
// rather than guessing a grouping key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git"), None),
|
||||
root.to_path_buf()
|
||||
);
|
||||
assert_eq!(repo_home(root, None, None), root.to_path_buf());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The `.gitignore` chain a directory listing is scored against.
|
||||
//!
|
||||
//! One matcher is compiled per directory that has a `.gitignore`, cached by
|
||||
//! that directory's path, and a path is scored by walking the chain from the
|
||||
//! tree root down to the path's own parent — **the deepest match wins**, so a
|
||||
//! nested `.gitignore`'s whitelist (`!pattern`) can un-ignore what an ancestor
|
||||
//! ignored, which is what git itself does.
|
||||
//!
|
||||
//! Lives in `tty7-core` rather than beside the file tree because the answer has
|
||||
//! to be identical on both sides of a remote workspace: the GUI dims ignored
|
||||
//! entries for a local tree, and the server has to dim exactly the same ones
|
||||
//! for a remote tree. One implementation, no drift.
|
||||
//!
|
||||
//! Compiling is lazy and cached (including the negative case — a directory with
|
||||
//! no `.gitignore` caches as `None`), so a chain that is carried across
|
||||
//! listings pays for each directory once. `Arc`, so a chain can be cloned onto
|
||||
//! a background thread and its compiled matchers shared rather than rebuilt.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use ignore::gitignore::Gitignore;
|
||||
|
||||
/// Compiled `.gitignore` matchers, keyed by the directory each came from
|
||||
/// (`None` = that directory has no `.gitignore`).
|
||||
#[derive(Default, Clone)]
|
||||
pub struct GitignoreChain {
|
||||
matchers: HashMap<PathBuf, Option<Arc<Gitignore>>>,
|
||||
}
|
||||
|
||||
impl GitignoreChain {
|
||||
/// Walk the `.gitignore` chain from `root` down to `path`'s directory and
|
||||
/// report whether `path` ends up ignored; the deepest match wins
|
||||
/// (whitelist `!patterns` un-ignore).
|
||||
///
|
||||
/// `is_dir` matters because gitignore patterns can be directory-only
|
||||
/// (`build/`). Paths outside `root` simply score against nothing.
|
||||
pub fn is_ignored(&mut self, path: &Path, is_dir: bool, root: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let mut state = false;
|
||||
// Ancestor chain root → parent, in order.
|
||||
let mut chain: Vec<&Path> = parent
|
||||
.ancestors()
|
||||
.take_while(|a| a.starts_with(root))
|
||||
.collect();
|
||||
chain.reverse();
|
||||
for dir in chain {
|
||||
let gi = self
|
||||
.matchers
|
||||
.entry(dir.to_path_buf())
|
||||
.or_insert_with(|| {
|
||||
let file = dir.join(".gitignore");
|
||||
file.is_file().then(|| {
|
||||
let (gi, _err) = Gitignore::new(&file);
|
||||
Arc::new(gi)
|
||||
})
|
||||
})
|
||||
.clone();
|
||||
let Some(gi) = gi else { continue };
|
||||
let Ok(rel) = path.strip_prefix(dir) else {
|
||||
continue;
|
||||
};
|
||||
match gi.matched(rel, is_dir) {
|
||||
ignore::Match::Ignore(_) => state = true,
|
||||
ignore::Match::Whitelist(_) => state = false,
|
||||
ignore::Match::None => {}
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
/// Fold another chain's compiled matchers in — how a background listing
|
||||
/// hands back the ones it had to compile so the next listing re-uses them.
|
||||
pub fn absorb(&mut self, other: Self) {
|
||||
self.matchers.extend(other.matchers);
|
||||
}
|
||||
|
||||
/// Drop every compiled matcher, so the next scoring recompiles from disk.
|
||||
/// The invalidation a `.gitignore` edit triggers.
|
||||
pub fn clear(&mut self) {
|
||||
self.matchers.clear();
|
||||
}
|
||||
|
||||
/// How many directories have been scored (and so cached) so far — the
|
||||
/// negative entries for directories without a `.gitignore` included.
|
||||
pub fn len(&self) -> usize {
|
||||
self.matchers.len()
|
||||
}
|
||||
|
||||
/// Whether nothing has been compiled or cached yet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.matchers.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write a `.gitignore` into `dir` (creating it) with the given patterns.
|
||||
fn write_ignore(dir: &Path, body: &str) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join(".gitignore"), body).unwrap();
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("tty7-gitignore-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// The deepest `.gitignore` wins, so a nested whitelist un-ignores what the
|
||||
/// root ignored — the rule the file tree's dimming depends on.
|
||||
#[test]
|
||||
fn the_deepest_match_wins() {
|
||||
let root = scratch("deepest");
|
||||
write_ignore(&root, "*.log\n");
|
||||
write_ignore(&root.join("keep"), "!important.log\n");
|
||||
|
||||
let mut chain = GitignoreChain::default();
|
||||
assert!(chain.is_ignored(&root.join("a.log"), false, &root));
|
||||
assert!(chain.is_ignored(&root.join("keep/other.log"), false, &root));
|
||||
assert!(!chain.is_ignored(&root.join("keep/important.log"), false, &root));
|
||||
assert!(!chain.is_ignored(&root.join("a.txt"), false, &root));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A directory-only pattern (`build/`) matches the directory, not a file of
|
||||
/// the same name — which is why scoring takes `is_dir`.
|
||||
#[test]
|
||||
fn directory_only_patterns_need_is_dir() {
|
||||
let root = scratch("dironly");
|
||||
write_ignore(&root, "build/\n");
|
||||
|
||||
let mut chain = GitignoreChain::default();
|
||||
assert!(chain.is_ignored(&root.join("build"), true, &root));
|
||||
assert!(!chain.is_ignored(&root.join("build"), false, &root));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `clear` forces a recompile, so an edited `.gitignore` takes effect;
|
||||
/// without it the cached matcher would answer from the old patterns.
|
||||
#[test]
|
||||
fn clear_lets_an_edited_gitignore_take_effect() {
|
||||
let root = scratch("clear");
|
||||
write_ignore(&root, "*.log\n");
|
||||
|
||||
let mut chain = GitignoreChain::default();
|
||||
assert!(chain.is_ignored(&root.join("a.log"), false, &root));
|
||||
|
||||
write_ignore(&root, "*.tmp\n");
|
||||
assert!(
|
||||
chain.is_ignored(&root.join("a.log"), false, &root),
|
||||
"cached"
|
||||
);
|
||||
chain.clear();
|
||||
assert!(!chain.is_ignored(&root.join("a.log"), false, &root));
|
||||
assert!(chain.is_ignored(&root.join("a.tmp"), false, &root));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The *naming* half of the SSH credential vault: how a keychain entry is
|
||||
//! addressed, and the secret-free pointer that `config.json` persists.
|
||||
//!
|
||||
//! Secrets (passwords, private-key passphrases) live only in the platform secret
|
||||
//! store — never in `config.json`. A profile persists at most a [`CredentialRef`],
|
||||
//! which *names* a keychain entry but carries no secret. Per PRD §7.2 entries are
|
||||
//! keyed by **endpoint**, not by profile:
|
||||
//!
|
||||
//! - passwords → service `tty7-ssh`, account `<user>@<host>:<port>`
|
||||
//! - key passphrases → service `tty7-ssh-key`, account `<sha512-hex of key file>`
|
||||
//!
|
||||
//! Endpoint keying lets a QuickConnect (which has no profile) still "remember" a
|
||||
//! password, lets several profiles pointing at one endpoint share one credential,
|
||||
//! and means changing a password touches exactly one entry.
|
||||
//!
|
||||
//! **The store itself is not here.** The `CredentialStore` trait, its OS-keychain
|
||||
//! backend and the in-memory test double live in the GUI crate
|
||||
//! (`tty7::core::keychain`), because nothing in this crate reads or writes a
|
||||
//! secret: the daemon receives already-resolved secrets on the wire (see
|
||||
//! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on
|
||||
//! boxes that have no OS keychain at all. Keeping `keyring` out of this crate's
|
||||
//! manifest is what keeps a static `tty7-server` from linking the whole
|
||||
//! `zbus`/`secret-service` stack it can never use — see the design doc §11.
|
||||
//!
|
||||
//! What has to stay is exactly what `Config` needs to parse `config.json`
|
||||
//! identically on the server: the account-naming scheme and [`CredentialRef`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
/// Keychain service name for endpoint passwords.
|
||||
pub const SERVICE_PASSWORD: &str = "tty7-ssh";
|
||||
/// Keychain service name for private-key passphrases.
|
||||
pub const SERVICE_KEY_PASSPHRASE: &str = "tty7-ssh-key";
|
||||
|
||||
/// Which kind of secret a [`CredentialRef`] points at. The kind selects the
|
||||
/// keychain *service*; the ref's `account` selects the entry within it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CredentialKind {
|
||||
/// An endpoint password (`tty7-ssh` service, `user@host:port` account).
|
||||
#[default]
|
||||
Password,
|
||||
/// A private-key passphrase (`tty7-ssh-key` service, key-sha512-hex account).
|
||||
KeyPassphrase,
|
||||
}
|
||||
|
||||
impl CredentialKind {
|
||||
/// The keychain service name this kind stores under.
|
||||
pub fn service(self) -> &'static str {
|
||||
match self {
|
||||
CredentialKind::Password => SERVICE_PASSWORD,
|
||||
CredentialKind::KeyPassphrase => SERVICE_KEY_PASSPHRASE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A persisted, secret-free pointer to a keychain entry. This is the only
|
||||
/// credential-related thing that ever lands in `config.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CredentialRef {
|
||||
/// Whether this names a password or a key passphrase.
|
||||
#[serde(deserialize_with = "crate::core::config::de_lenient")]
|
||||
pub kind: CredentialKind,
|
||||
/// The keychain "account": `user@host:port` for [`CredentialKind::Password`],
|
||||
/// or the sha512-hex of the key-file contents for
|
||||
/// [`CredentialKind::KeyPassphrase`].
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
impl Default for CredentialRef {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::Password,
|
||||
account: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialRef {
|
||||
/// Reference the password entry for an endpoint.
|
||||
pub fn password(user: &str, host: &str, port: u16) -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::Password,
|
||||
account: endpoint_account(user, host, port),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference the passphrase entry for a private key, given the sha512-hex of
|
||||
/// its file contents (see [`key_account_from_contents`]).
|
||||
pub fn key_passphrase(key_sha512_hex: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::KeyPassphrase,
|
||||
account: key_sha512_hex.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The keychain service this ref resolves under.
|
||||
pub fn service(&self) -> &'static str {
|
||||
self.kind.service()
|
||||
}
|
||||
}
|
||||
|
||||
/// The endpoint account string used to key a password entry: `user@host:port`.
|
||||
pub fn endpoint_account(user: &str, host: &str, port: u16) -> String {
|
||||
format!("{user}@{host}:{port}")
|
||||
}
|
||||
|
||||
/// The account string used to key a private-key passphrase entry: the lowercase
|
||||
/// sha512-hex digest of the key file's raw contents. Endpoint-independent, so the
|
||||
/// same encrypted key reused across hosts shares one stored passphrase.
|
||||
///
|
||||
/// Only the GUI calls this — it is the side that reads the key file — but the
|
||||
/// account *name* is part of the persisted config contract, the same as
|
||||
/// [`endpoint_account`], so both halves of PRD §7.2's keying scheme stay in one
|
||||
/// place rather than drifting apart across the crate boundary.
|
||||
pub fn key_account_from_contents(key_bytes: &[u8]) -> String {
|
||||
let digest = Sha512::digest(key_bytes);
|
||||
// Lowercase hex, no separators.
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_and_key_accounts_are_stable() {
|
||||
assert_eq!(
|
||||
endpoint_account("deploy", "10.0.0.5", 22),
|
||||
"deploy@10.0.0.5:22"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_account("deploy", "10.0.0.5", 2222),
|
||||
"deploy@10.0.0.5:2222"
|
||||
);
|
||||
|
||||
// sha512 hex is 128 chars, lowercase, and deterministic.
|
||||
let a = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n");
|
||||
let b = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), 128);
|
||||
assert!(
|
||||
a.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
);
|
||||
assert_ne!(a, key_account_from_contents(b"different"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_kind_selects_service() {
|
||||
assert_eq!(CredentialKind::Password.service(), "tty7-ssh");
|
||||
assert_eq!(CredentialKind::KeyPassphrase.service(), "tty7-ssh-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_ref_round_trips_and_hides_secret() {
|
||||
let cref = CredentialRef::password("deploy", "10.0.0.5", 22);
|
||||
let json = serde_json::to_string(&cref).unwrap();
|
||||
// Only kind + account are serialized — never a secret.
|
||||
assert!(json.contains("deploy@10.0.0.5:22"));
|
||||
assert!(json.contains("password"));
|
||||
let back: CredentialRef = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, cref);
|
||||
|
||||
// A bad `kind` value falls back to the default rather than failing the parse.
|
||||
let lenient: CredentialRef =
|
||||
serde_json::from_str(r#"{"kind":"bogus","account":"x"}"#).unwrap();
|
||||
assert_eq!(lenient.kind, CredentialKind::Password);
|
||||
assert_eq!(lenient.account, "x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! File logger — the `log::` records that otherwise go nowhere.
|
||||
//!
|
||||
//! tty7 depends on the `log` facade but shipped no backend, so every
|
||||
//! `log::info!` / `log::warn!` in the tree was compiled in and then discarded.
|
||||
//! That is survivable for the GUI, which can put a failure on screen. It is not
|
||||
//! survivable for the **daemon**: [`crate::daemon::spawn`] detaches it with its
|
||||
//! stdio pointed at `/dev/null`, so a remote install that refused, a connection
|
||||
//! that dropped, or a pane that died left no trace anywhere — the only artifact
|
||||
//! the process could produce was `crash.log`, and only if it panicked.
|
||||
//!
|
||||
//! So: one append-only file next to `crash.log`, same size cap and same
|
||||
//! best-effort discipline. Logging must never be the reason something fails.
|
||||
//!
|
||||
//! ## Level
|
||||
//!
|
||||
//! `TTY7_LOG` (or `RUST_LOG`) sets it — `off` / `error` / `warn` / `info` /
|
||||
//! `debug` / `trace`. **Default `off`**: this writes to a user's disk forever,
|
||||
//! and a terminal that logs by default is a terminal that fills a disk while
|
||||
//! nobody is watching. Ask for it when diagnosing, which is also the only time
|
||||
//! the records are worth anything.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
/// Rewrite the log once it passes this. Same cap as `crash.log`, larger because
|
||||
/// a debug session produces many small lines rather than a few big backtraces.
|
||||
const MAX_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
struct FileLogger {
|
||||
role: &'static str,
|
||||
path: PathBuf,
|
||||
/// Serializes writes so two threads cannot interleave halves of a line.
|
||||
/// Contended only while logging is on, which is not the default.
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Log for FileLogger {
|
||||
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
|
||||
metadata.level() <= log::max_level()
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let mut line = String::new();
|
||||
let _ = write!(
|
||||
line,
|
||||
"{} {:5} {} [{}] {}\n",
|
||||
timestamp(),
|
||||
record.level(),
|
||||
self.role,
|
||||
record.target(),
|
||||
record.args(),
|
||||
);
|
||||
let _guard = self.lock.lock();
|
||||
append(&self.path, &line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger for this process, if the environment asks for one.
|
||||
///
|
||||
/// `role` labels the records, since the GUI and the daemon it spawns share one
|
||||
/// config dir and therefore one log file — the same convention `crash.log`
|
||||
/// uses, and the reason a line can be attributed at all.
|
||||
///
|
||||
/// Idempotent and silent on failure: a second call, a missing config dir, or a
|
||||
/// read-only disk all leave the process running with no logger, which is
|
||||
/// exactly what it had before.
|
||||
pub fn install(role: &'static str) {
|
||||
let level = level_from_env();
|
||||
if level == LevelFilter::Off {
|
||||
return;
|
||||
}
|
||||
let Some(path) = log_path() else {
|
||||
return;
|
||||
};
|
||||
// A `static` rather than `set_boxed_logger`, which needs `log`'s `std`
|
||||
// feature — not enabled here, and not worth enabling for one allocation
|
||||
// that lives for the whole process anyway. `OnceLock` is also what makes a
|
||||
// second call harmless.
|
||||
static LOGGER: OnceLock<FileLogger> = OnceLock::new();
|
||||
let logger = LOGGER.get_or_init(|| FileLogger {
|
||||
role,
|
||||
path,
|
||||
lock: Mutex::new(()),
|
||||
});
|
||||
if log::set_logger(logger).is_ok() {
|
||||
log::set_max_level(level);
|
||||
}
|
||||
}
|
||||
|
||||
/// `TTY7_LOG` first, then `RUST_LOG` — the former so turning on tty7's logging
|
||||
/// does not also turn on every library that reads `RUST_LOG`.
|
||||
///
|
||||
/// Only a bare level is understood, not `RUST_LOG`'s per-module syntax: a
|
||||
/// half-supported filter language is worse than an obvious one, because
|
||||
/// `TTY7_LOG=tty7_core::daemon=debug` would silently mean "off".
|
||||
fn level_from_env() -> LevelFilter {
|
||||
let raw = std::env::var("TTY7_LOG")
|
||||
.or_else(|_| std::env::var("RUST_LOG"))
|
||||
.unwrap_or_default();
|
||||
parse_level(&raw)
|
||||
}
|
||||
|
||||
fn parse_level(raw: &str) -> LevelFilter {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"error" => LevelFilter::Error,
|
||||
"warn" | "warning" => LevelFilter::Warn,
|
||||
"info" => LevelFilter::Info,
|
||||
"debug" => LevelFilter::Debug,
|
||||
"trace" => LevelFilter::Trace,
|
||||
_ => LevelFilter::Off,
|
||||
}
|
||||
}
|
||||
|
||||
fn append(path: &PathBuf, record: &str) {
|
||||
use std::io::Write as _;
|
||||
let truncate = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES);
|
||||
let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(!truncate)
|
||||
.write(true)
|
||||
.truncate(truncate)
|
||||
.open(path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = file.write_all(record.as_bytes());
|
||||
let _ = file.flush();
|
||||
}
|
||||
|
||||
fn log_path() -> Option<PathBuf> {
|
||||
crate::core::config::config_path("tty7.log")
|
||||
}
|
||||
|
||||
/// `HH:MM:SS.mmm` — the time of day, which is what you compare against "I
|
||||
/// clicked it just now". The date is in `crash.log`'s records and in the file's
|
||||
/// own mtime; repeating it on every line would cost more than it tells.
|
||||
fn timestamp() -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = now.as_secs() % 86_400;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
secs / 3600,
|
||||
(secs % 3600) / 60,
|
||||
secs % 60,
|
||||
now.subsec_millis()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Level;
|
||||
|
||||
/// The default has to be `Off`. A terminal that logs to disk unasked fills
|
||||
/// a disk on a machine nobody is watching — and the daemon outlives every
|
||||
/// window, so there is no session boundary to bound it.
|
||||
#[test]
|
||||
fn logging_is_off_unless_asked_for() {
|
||||
// Not via the environment: mutating it is `unsafe` in edition 2024 and
|
||||
// races every other test in the binary. The parser is the whole
|
||||
// decision, so it is what gets tested.
|
||||
assert_eq!(parse_level(""), LevelFilter::Off);
|
||||
assert_eq!(parse_level(" "), LevelFilter::Off);
|
||||
assert_eq!(parse_level("nonsense"), LevelFilter::Off);
|
||||
// `RUST_LOG`'s per-module syntax is deliberately *not* half-supported.
|
||||
assert_eq!(parse_level("tty7_core::daemon=debug"), LevelFilter::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_parse_case_and_space_insensitively() {
|
||||
assert_eq!(parse_level("debug"), LevelFilter::Debug);
|
||||
assert_eq!(parse_level(" DEBUG "), LevelFilter::Debug);
|
||||
assert_eq!(parse_level("Warn"), LevelFilter::Warn);
|
||||
assert_eq!(parse_level("warning"), LevelFilter::Warn);
|
||||
assert_eq!(parse_level("TRACE"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
/// A run away log must not grow without bound: past the cap the file is
|
||||
/// rewritten rather than appended to.
|
||||
#[test]
|
||||
fn the_file_is_rewritten_once_it_passes_the_cap() {
|
||||
let path = std::env::temp_dir().join(format!("tty7-logfile-{}.log", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
append(&path, "first\n");
|
||||
append(&path, "second\n");
|
||||
let both = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(both.contains("first") && both.contains("second"), "appends");
|
||||
|
||||
std::fs::write(&path, vec![b'x'; (MAX_BYTES + 1) as usize]).unwrap();
|
||||
append(&path, "after the cap\n");
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(after, "after the cap\n", "rewritten, not appended");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Records name which process wrote them: the GUI and the daemon it spawns
|
||||
/// share one config dir, so an unattributed line is ambiguous exactly when
|
||||
/// it matters (which side dropped the connection?).
|
||||
#[test]
|
||||
fn a_record_names_its_role_and_target() {
|
||||
let path = std::env::temp_dir().join(format!("tty7-logrec-{}.log", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let logger = FileLogger {
|
||||
role: "daemon",
|
||||
path: path.clone(),
|
||||
lock: Mutex::new(()),
|
||||
};
|
||||
log::set_max_level(LevelFilter::Info);
|
||||
logger.log(
|
||||
&Record::builder()
|
||||
.args(format_args!("remote build-box: installed tty7-server"))
|
||||
.level(Level::Info)
|
||||
.target("tty7_core::daemon::install")
|
||||
.build(),
|
||||
);
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("daemon"), "{written}");
|
||||
assert!(written.contains("tty7_core::daemon::install"), "{written}");
|
||||
assert!(written.contains("installed tty7-server"), "{written}");
|
||||
assert!(written.contains("INFO"), "{written}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Domain core: the configuration model, session persistence, the streaming
|
||||
//! OSC tokenizer shared by the daemon- and client-side output scanners, and the
|
||||
//! shell / agent / git knowledge the daemon and the GUI have to share.
|
||||
//!
|
||||
//! These modules are framework-light and depend on neither `ui` nor `terminal`
|
||||
//! — the dependency arrow always points *inward* to here. That is what let them
|
||||
//! lift out of the GUI binary into this crate without untangling view code.
|
||||
//!
|
||||
//! The GUI crate re-exports this module as `crate::core`, adding its own
|
||||
//! gpui-facing modules (`actions`, `update`, …) and thin gpui layers over
|
||||
//! `config`, `session` and `window_state`, so call sites there are unchanged.
|
||||
|
||||
pub mod agent_hooks;
|
||||
pub mod cli_agent;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
pub mod git;
|
||||
pub mod gitignore;
|
||||
pub mod logfile;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
// so parts of it read as dead code until those merge.
|
||||
#[allow(dead_code)]
|
||||
pub mod keychain;
|
||||
pub mod osc;
|
||||
pub mod proc;
|
||||
pub mod session;
|
||||
pub mod shells;
|
||||
#[allow(dead_code)]
|
||||
pub mod ssh_profile;
|
||||
pub mod threads;
|
||||
pub mod window_state;
|
||||
pub mod workspace_store;
|
||||
pub mod worktree;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
//! Persisted last-window geometry, stored at `window.json` in the config dir
|
||||
//! (alongside `config.json` / `session.json`). The quit hook in `ui::app`
|
||||
//! writes the window's final bounds here unconditionally; startup reads it
|
||||
//! back only when `Config::remember_window_size` is on, so toggling the
|
||||
//! setting off and on again still restores the most recent quit's geometry.
|
||||
//! Same durability contract as the other config-dir files: missing/malformed
|
||||
//! reads fall back to "nothing remembered", writes are atomic.
|
||||
//!
|
||||
//! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because
|
||||
//! [`Workspace`](super::session::Workspace) embeds it and `session.json` has to
|
||||
//! parse without gpui. Converting to and from `Bounds` is the GUI crate's job —
|
||||
//! see its `core::window_state::WindowGeometry` extension trait.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Don't restore a window smaller than this (logical px) — a corrupt or
|
||||
/// hand-edited file shouldn't reopen tty7 as a sliver.
|
||||
const MIN_SIZE: f32 = 200.0;
|
||||
|
||||
/// Last known window geometry, in gpui's global coordinate space (logical
|
||||
/// pixels; origins can be negative or beyond the primary display on
|
||||
/// multi-monitor setups). For a fullscreen window this records the *restore*
|
||||
/// bounds, so the next normal launch isn't screen-sized.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
crate::core::config::config_path("window.json")
|
||||
}
|
||||
|
||||
/// Load the remembered geometry; `None` when nothing usable is on disk
|
||||
/// (never saved, unreadable, malformed, or degenerate values), in which
|
||||
/// case the caller falls back to the centered default.
|
||||
pub fn load() -> Option<Self> {
|
||||
let path = Self::path()?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let state: Self = serde_json::from_str(&text)
|
||||
.map_err(|e| log::warn!("failed to parse {}: {e}; ignoring", path.display()))
|
||||
.ok()?;
|
||||
state.is_usable().then_some(state)
|
||||
}
|
||||
|
||||
/// A geometry worth restoring: all values finite and the size at least
|
||||
/// [`MIN_SIZE`] each way.
|
||||
fn is_usable(&self) -> bool {
|
||||
[self.x, self.y, self.width, self.height]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
&& self.width >= MIN_SIZE
|
||||
&& self.height >= MIN_SIZE
|
||||
}
|
||||
|
||||
/// Persist the geometry; IO / serialization errors are logged and swallowed
|
||||
/// (worst case the next launch opens at the default size).
|
||||
pub fn save(&self) {
|
||||
let Some(path) = Self::path() else {
|
||||
return;
|
||||
};
|
||||
let json = match serde_json::to_string_pretty(self) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("failed to serialize window state: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) {
|
||||
log::warn!("failed to write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_degenerate_geometry() {
|
||||
let usable =
|
||||
|json: &str| serde_json::from_str::<WindowState>(json).is_ok_and(|s| s.is_usable());
|
||||
assert!(usable(r#"{"x":-120.5,"y":42,"width":1440,"height":900}"#));
|
||||
assert!(!usable(r#"{"x":0,"y":0,"width":50,"height":900}"#));
|
||||
assert!(!usable(r#"{"x":null,"y":0,"width":1440,"height":900}"#));
|
||||
assert!(!usable("not json"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,23 @@
|
||||
//! repository's own `.tty7/worktrees/` (kept out of `git status` by an
|
||||
//! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an
|
||||
//! isolated checkout on its own branch, physically next to the code it forks.
|
||||
//! Blocking (spawns `git`); callers run it on the background executor, except
|
||||
//! [`is_inside_repo`], which is a pure filesystem probe cheap enough for
|
||||
//! menu-open time.
|
||||
//!
|
||||
//! Every filesystem touch and every `git` invocation goes through the [`Host`]
|
||||
//! the pane belongs to, so a worktree is created on the machine the code
|
||||
//! actually lives on rather than always on this one. That also means **all of
|
||||
//! it blocks** — on a remote host every call here is a round trip — so callers
|
||||
//! run the whole module on the background executor (`ui::host_ops`), never
|
||||
//! inline while building a menu.
|
||||
//!
|
||||
//! Path arithmetic is deliberately the host's too ([`Host::join`], never
|
||||
//! `PathBuf::join`): a Windows client driving a Linux host would otherwise
|
||||
//! build `/home/me\.tty7` and create a repository directory with a backslash in
|
||||
//! its name.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::host::Host;
|
||||
|
||||
/// Word pools for generated branch names (`quiet-otter`). Short, lowercase,
|
||||
/// branch-safe; two pools of 24 give 576 combinations before the numeric
|
||||
/// fallback in [`defaults`] kicks in.
|
||||
@@ -52,32 +63,40 @@ pub struct WorktreeDefaults {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Whether `cwd` sits inside a git repository — an upward scan for `.git`
|
||||
/// (a directory in a primary checkout, a file in a linked worktree or
|
||||
/// submodule). No subprocess: the tab context menu calls this while opening.
|
||||
pub fn is_inside_repo(cwd: &Path) -> bool {
|
||||
cwd.ancestors().any(|d| d.join(".git").exists())
|
||||
/// `<root>/.tty7/worktrees` — where this repository's managed checkouts live.
|
||||
/// Built with [`Host::join`] rather than [`PathBuf::join`] so the separator is
|
||||
/// the *host's*, not the client's.
|
||||
fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf {
|
||||
host.join(&host.join(main_root, ".tty7"), "worktrees")
|
||||
}
|
||||
|
||||
/// Run `git -C <dir> <args>`, returning trimmed stdout on success and trimmed
|
||||
/// stderr as the error otherwise.
|
||||
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = crate::core::proc::hide_console(&mut cmd)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run git: {e}"))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
/// Run `git -C <dir> <args>` on `host`, returning trimmed stdout on success and
|
||||
/// trimmed stderr as the error otherwise.
|
||||
///
|
||||
/// The shape is unchanged from when this module ran `git` itself; what changed
|
||||
/// is that the invocation is now the one every git read in tty7 shares
|
||||
/// (`core::git::git_output`): `GIT_OPTIONAL_LOCKS=0`, stdin nulled, ambient
|
||||
/// `GIT_DIR`/`GIT_WORK_TREE` removed. `GIT_OPTIONAL_LOCKS` only suppresses
|
||||
/// *optional* sub-operations (git's own words) — the locks `worktree add` and
|
||||
/// `branch -d` need to do their job are not optional and are still taken.
|
||||
///
|
||||
/// The three outcomes stay distinct: `Err` from the host means git never ran
|
||||
/// (missing binary, vanished cwd, dead connection) and carries the same
|
||||
/// `failed to run git:` prefix this module has always produced, while a git
|
||||
/// that ran and failed still reports its own stderr.
|
||||
fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
match host.git(dir, args) {
|
||||
Ok(out) if out.success() => Ok(out.stdout_trimmed()),
|
||||
Ok(out) => Err(out.stderr_trimmed()),
|
||||
Err(e) => Err(format!("failed to run git: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` already exists as a local branch in the repo at `repo_root`.
|
||||
/// A failed probe (`--verify --quiet` exits non-zero) means it's free.
|
||||
fn branch_exists(repo_root: &Path, name: &str) -> bool {
|
||||
fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool {
|
||||
git(
|
||||
host,
|
||||
repo_root,
|
||||
&[
|
||||
"rev-parse",
|
||||
@@ -128,21 +147,20 @@ pub struct ManagedWorktree {
|
||||
/// sits anywhere else. Only checkouts under the main repository's
|
||||
/// `.tty7/worktrees/` count — a user's own linked worktrees are never offered
|
||||
/// for removal. Blocking (spawns `git`).
|
||||
pub fn managed(cwd: &Path) -> Option<ManagedWorktree> {
|
||||
pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
|
||||
// Canonicalize before the component test: git reports resolved physical
|
||||
// paths (`/private/var/…` on macOS), while `cwd` may arrive through
|
||||
// symlinks — a textual comparison would then never match. The `.tty7/
|
||||
// worktrees` ancestor check is a cheap pure-filesystem pre-filter, so the
|
||||
// worktrees` ancestor check is a cheap pure-textual pre-filter, so the
|
||||
// common case (every ordinary tab close) never spawns git.
|
||||
let cwd = std::fs::canonicalize(cwd).ok()?;
|
||||
if !cwd
|
||||
.ancestors()
|
||||
.any(|a| a.ends_with(Path::new(".tty7").join("worktrees")))
|
||||
{
|
||||
let cwd = host.canonicalize(cwd).ok()?;
|
||||
let suffix = host.join(Path::new(".tty7"), "worktrees");
|
||||
if !cwd.ancestors().any(|a| a.ends_with(&suffix)) {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(git(&cwd, &["rev-parse", "--show-toplevel"]).ok()?);
|
||||
let path = PathBuf::from(git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?);
|
||||
let main_root = git(
|
||||
host,
|
||||
&path,
|
||||
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
)
|
||||
@@ -152,11 +170,13 @@ pub fn managed(cwd: &Path) -> Option<ManagedWorktree> {
|
||||
.to_path_buf();
|
||||
// The checkout must really sit in *this* repository's managed directory —
|
||||
// both paths come from git, so they compare on equal (physical) footing.
|
||||
if !path.starts_with(main_root.join(".tty7").join("worktrees")) {
|
||||
if !path.starts_with(managed_root(host, &main_root)) {
|
||||
return None;
|
||||
}
|
||||
let branch = git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
let dirty = !git(&path, &["status", "--porcelain"]).ok()?.is_empty();
|
||||
let branch = git(host, &path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
let dirty = !git(host, &path, &["status", "--porcelain"])
|
||||
.ok()?
|
||||
.is_empty();
|
||||
Some(ManagedWorktree {
|
||||
path,
|
||||
branch,
|
||||
@@ -172,26 +192,26 @@ pub fn managed(cwd: &Path) -> Option<ManagedWorktree> {
|
||||
/// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix
|
||||
/// that git-reported paths lack, so comparing raw would never match. A
|
||||
/// vanished path never counts as occupying.
|
||||
pub fn occupied(path: &Path, cwds: &[PathBuf]) -> bool {
|
||||
let Ok(path) = std::fs::canonicalize(path) else {
|
||||
pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool {
|
||||
let Ok(path) = host.canonicalize(path) else {
|
||||
return false;
|
||||
};
|
||||
cwds.iter()
|
||||
.any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(&path)))
|
||||
.any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path)))
|
||||
}
|
||||
|
||||
/// Remove a managed worktree (`git worktree remove`, `--force` to discard
|
||||
/// uncommitted changes), then best-effort delete its branch with `-d` — so a
|
||||
/// branch carrying unmerged commits survives the cleanup.
|
||||
pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> {
|
||||
pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), String> {
|
||||
let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?;
|
||||
let mut args = vec!["worktree", "remove"];
|
||||
if force {
|
||||
args.push("--force");
|
||||
}
|
||||
args.push(path);
|
||||
git(&wt.main_root, &args)?;
|
||||
let _ = git(&wt.main_root, &["branch", "-d", &wt.branch]);
|
||||
git(host, &wt.main_root, &args)?;
|
||||
let _ = git(host, &wt.main_root, &["branch", "-d", &wt.branch]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -200,11 +220,12 @@ pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> {
|
||||
/// the *main* repository even when `cwd` is itself inside a linked worktree
|
||||
/// (a worktree tab spawning another worktree), so checkouts never nest. The
|
||||
/// common git-dir is `<main>/.git`, whose parent is the main root.
|
||||
fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
let repo_root = git(cwd, &["rev-parse", "--show-toplevel"])
|
||||
fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"])
|
||||
.map_err(|_| "not inside a git repository".to_string())?;
|
||||
let repo_root = PathBuf::from(repo_root);
|
||||
let main_root = git(
|
||||
host,
|
||||
cwd,
|
||||
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
)
|
||||
@@ -212,7 +233,7 @@ fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
.map(PathBuf::from)
|
||||
.and_then(|d| d.parent().map(Path::to_path_buf))
|
||||
.unwrap_or_else(|| repo_root.clone());
|
||||
let dir = main_root.join(".tty7").join("worktrees");
|
||||
let dir = managed_root(host, &main_root);
|
||||
Ok((repo_root, dir))
|
||||
}
|
||||
|
||||
@@ -220,15 +241,15 @@ fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
/// (retried until both the branch and the directory are unused, with a
|
||||
/// numeric-suffix fallback so a saturated pool still terminates) and the
|
||||
/// currently checked-out branch as the start point.
|
||||
pub fn defaults(cwd: &Path) -> Result<WorktreeDefaults, String> {
|
||||
let (repo_root, dir) = repo_dir(cwd)?;
|
||||
pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String> {
|
||||
let (repo_root, dir) = repo_dir(host, cwd)?;
|
||||
|
||||
let mut state = seed();
|
||||
let mut name = candidate(&mut state);
|
||||
for attempt in 0..64 {
|
||||
// Both the ref and the directory must be free — a stale directory from a
|
||||
// hand-removed worktree would make `git worktree add` fail either way.
|
||||
if !branch_exists(&repo_root, &name) && !dir.join(&name).exists() {
|
||||
if !branch_exists(host, &repo_root, &name) && !host.exists(&host.join(&dir, &name)) {
|
||||
break;
|
||||
}
|
||||
name = if attempt < 32 {
|
||||
@@ -239,7 +260,7 @@ pub fn defaults(cwd: &Path) -> Result<WorktreeDefaults, String> {
|
||||
}
|
||||
|
||||
// Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD.
|
||||
let base = git(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
let base = git(host, &repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.unwrap_or_else(|_| "HEAD".to_string());
|
||||
Ok(WorktreeDefaults { name, base, dir })
|
||||
}
|
||||
@@ -248,30 +269,32 @@ pub fn defaults(cwd: &Path) -> Result<WorktreeDefaults, String> {
|
||||
/// `<main-root>/.tty7/worktrees/<name>`, on new branch `branch` starting from
|
||||
/// `base`. Branch and base validity is git's to judge; the directory name only
|
||||
/// has to stay a single path component so it can't escape the managed root.
|
||||
pub fn create(cwd: &Path, req: &WorktreeRequest) -> Result<NewWorktree, String> {
|
||||
pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewWorktree, String> {
|
||||
if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\'])
|
||||
{
|
||||
return Err(format!("invalid worktree name \"{}\"", req.name));
|
||||
}
|
||||
let (repo_root, dir) = repo_dir(cwd)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
let (repo_root, dir) = repo_dir(host, cwd)?;
|
||||
host.create_dir(&dir, true)
|
||||
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
// A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included,
|
||||
// the ignore file itself too) out of the repository's `git status`, without
|
||||
// ever editing the repo's own .gitignore. Best-effort: a failed write only
|
||||
// costs status noise, never the worktree.
|
||||
let ignore = dir
|
||||
.parent()
|
||||
.expect(".tty7/worktrees has a parent")
|
||||
.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
let _ = std::fs::write(&ignore, "*\n");
|
||||
let ignore = host.join(
|
||||
dir.parent().expect(".tty7/worktrees has a parent"),
|
||||
".gitignore",
|
||||
);
|
||||
if !host.exists(&ignore) {
|
||||
let _ = host.write_file(&ignore, b"*\n");
|
||||
}
|
||||
|
||||
let path = dir.join(&req.name);
|
||||
if path.exists() {
|
||||
let path = host.join(&dir, &req.name);
|
||||
if host.exists(&path) {
|
||||
return Err(format!("{} already exists", path.display()));
|
||||
}
|
||||
git(
|
||||
host,
|
||||
&repo_root,
|
||||
&[
|
||||
"worktree",
|
||||
@@ -334,6 +357,13 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// The host every test drives: this machine. `LocalHost` is what the GUI
|
||||
/// hands these functions today, so testing against it tests the real path;
|
||||
/// a remote host is covered by the conformance suite instead.
|
||||
fn h() -> crate::host::SharedHost {
|
||||
crate::host::local::LocalHost::new()
|
||||
}
|
||||
|
||||
/// The simplest sensible request: directory and branch share `name`,
|
||||
/// starting from HEAD — what the sheet submits when nothing is edited.
|
||||
fn req(name: &str) -> WorktreeRequest {
|
||||
@@ -353,24 +383,13 @@ mod tests {
|
||||
assert!(NOUNS.contains(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_inside_repo_scans_upward_for_dot_git() {
|
||||
let repo = temp_repo("probe");
|
||||
let sub = repo.join("deep/nested");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
assert!(is_inside_repo(&sub));
|
||||
let plain = scratch("probe-plain");
|
||||
assert!(!is_inside_repo(&plain));
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
let _ = std::fs::remove_dir_all(&plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_proposes_fresh_name_current_branch_and_target_dir() {
|
||||
let h = h();
|
||||
let repo = temp_repo("dflt");
|
||||
let d = defaults(&repo).unwrap();
|
||||
assert!(!branch_exists(&repo, &d.name));
|
||||
let head = git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
let d = defaults(&*h, &repo).unwrap();
|
||||
assert!(!branch_exists(&*h, &repo, &d.name));
|
||||
let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
assert_eq!(d.base, head);
|
||||
// The target dir is the repo's own `.tty7/worktrees` (git reports the
|
||||
// canonical root: /var → /private/var on macOS).
|
||||
@@ -381,15 +400,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_makes_worktree_on_new_branch_inside_the_repo() {
|
||||
let h = h();
|
||||
let repo = temp_repo("repo");
|
||||
let wt = create(&repo, &req("quiet-otter")).unwrap();
|
||||
let wt = create(&*h, &repo, &req("quiet-otter")).unwrap();
|
||||
assert!(wt.path.join("a.txt").exists());
|
||||
assert!(branch_exists(&repo, &wt.branch));
|
||||
assert!(branch_exists(&*h, &repo, &wt.branch));
|
||||
// The worktree lands under `<repo>/.tty7/worktrees/<name>`…
|
||||
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
|
||||
assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter"));
|
||||
// …on the new branch…
|
||||
let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
assert_eq!(head, wt.branch);
|
||||
// …and the auto-written `.tty7/.gitignore` keeps the main repo's
|
||||
// status clean despite the checkout living inside it.
|
||||
@@ -397,10 +417,10 @@ mod tests {
|
||||
std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(),
|
||||
"*\n"
|
||||
);
|
||||
assert_eq!(git(&repo, &["status", "--porcelain"]).unwrap(), "");
|
||||
assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), "");
|
||||
// A second request colliding on the directory is refused up front.
|
||||
assert!(
|
||||
create(&repo, &req("quiet-otter"))
|
||||
create(&*h, &repo, &req("quiet-otter"))
|
||||
.unwrap_err()
|
||||
.contains("already exists")
|
||||
);
|
||||
@@ -409,6 +429,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_honors_custom_branch_and_base() {
|
||||
let h = h();
|
||||
let repo = temp_repo("base");
|
||||
// A `stable` branch one commit behind the default branch's HEAD.
|
||||
sh(&repo, &["git", "branch", "stable"]);
|
||||
@@ -416,6 +437,7 @@ mod tests {
|
||||
sh(&repo, &["git", "add", "."]);
|
||||
sh(&repo, &["git", "commit", "-q", "-m", "second"]);
|
||||
let wt = create(
|
||||
&*h,
|
||||
&repo,
|
||||
&WorktreeRequest {
|
||||
name: "my-dir".into(),
|
||||
@@ -426,7 +448,7 @@ mod tests {
|
||||
.unwrap();
|
||||
// Directory and branch names diverge as requested…
|
||||
assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir");
|
||||
let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
assert_eq!(head, "feat/my-branch");
|
||||
// …and the checkout starts from `stable` (no b.txt yet).
|
||||
assert!(wt.path.join("a.txt").exists());
|
||||
@@ -436,12 +458,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_rejects_escaping_names() {
|
||||
let h = h();
|
||||
let repo = temp_repo("names");
|
||||
for bad in ["", ".", "..", "a/b", "a\\b"] {
|
||||
let mut r = req("x");
|
||||
r.name = bad.into();
|
||||
assert!(
|
||||
create(&repo, &r)
|
||||
create(&*h, &repo, &r)
|
||||
.unwrap_err()
|
||||
.contains("invalid worktree name"),
|
||||
"{bad:?} should be rejected"
|
||||
@@ -452,21 +475,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_from_a_linked_worktree_lands_in_the_main_repo() {
|
||||
let h = h();
|
||||
let repo = temp_repo("nest");
|
||||
let first = create(&repo, &req("first-wt")).unwrap();
|
||||
let first = create(&*h, &repo, &req("first-wt")).unwrap();
|
||||
// Spawn the second worktree from *inside* the first: it must land in
|
||||
// the main repo's `.tty7/worktrees`, not nest inside the first checkout.
|
||||
let second = create(&first.path, &req("second-wt")).unwrap();
|
||||
let second = create(&*h, &first.path, &req("second-wt")).unwrap();
|
||||
assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap());
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_resolves_managed_checkouts_and_remove_cleans_up() {
|
||||
let h = h();
|
||||
let repo = temp_repo("mg");
|
||||
let wt = create(&repo, &req("mg-wt")).unwrap();
|
||||
let wt = create(&*h, &repo, &req("mg-wt")).unwrap();
|
||||
// The repo root itself is never "managed"…
|
||||
assert!(managed(&repo).is_none());
|
||||
assert!(managed(&*h, &repo).is_none());
|
||||
// …nor is a linked worktree the user made outside `.tty7/worktrees`.
|
||||
let own = scratch("mg-own");
|
||||
let _ = std::fs::remove_dir_all(&own);
|
||||
@@ -481,47 +506,122 @@ mod tests {
|
||||
own.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
assert!(managed(&own).is_none());
|
||||
assert!(managed(&*h, &own).is_none());
|
||||
// Any path inside the managed checkout resolves to it, initially clean.
|
||||
let sub = wt.path.join("sub");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
let m = managed(&sub).unwrap();
|
||||
let m = managed(&*h, &sub).unwrap();
|
||||
assert_eq!(m.branch, wt.branch);
|
||||
assert_eq!(m.path, wt.path);
|
||||
assert!(!m.dirty);
|
||||
// Uncommitted changes flip `dirty` and block a plain remove; --force
|
||||
// discards them. The branch (no unique commits) is deleted with it.
|
||||
std::fs::write(wt.path.join("b.txt"), "b").unwrap();
|
||||
let m = managed(&wt.path).unwrap();
|
||||
let m = managed(&*h, &wt.path).unwrap();
|
||||
assert!(m.dirty);
|
||||
assert!(remove(&m, false).is_err());
|
||||
remove(&m, true).unwrap();
|
||||
assert!(remove(&*h, &m, false).is_err());
|
||||
remove(&*h, &m, true).unwrap();
|
||||
assert!(!wt.path.exists());
|
||||
assert!(!branch_exists(&repo, &wt.branch));
|
||||
assert!(!branch_exists(&*h, &repo, &wt.branch));
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
let _ = std::fs::remove_dir_all(&own);
|
||||
}
|
||||
|
||||
/// `Host::git` runs every invocation with `GIT_OPTIONAL_LOCKS=0`, which
|
||||
/// this module did *not* set when it spawned `git` itself. That variable is
|
||||
/// the one thing in the unified invocation with any claim to affect a
|
||||
/// *write*, so the whole create → list → remove → delete-branch path is
|
||||
/// exercised end to end under it rather than argued about.
|
||||
///
|
||||
/// It is safe by git's own definition — "complete any requested operation
|
||||
/// without performing any optional sub-operations that require taking a
|
||||
/// lock" (`git(1)`, GIT_OPTIONAL_LOCKS). `worktree add` and `branch -d` are
|
||||
/// the *requested* operations, never optional sub-operations, and the locks
|
||||
/// they need are taken regardless. This test is what keeps that from being
|
||||
/// a reading of the manual: it fails if a git version ever decides
|
||||
/// otherwise.
|
||||
#[test]
|
||||
fn writes_survive_the_optional_locks_invariant() {
|
||||
let h = h();
|
||||
let repo = temp_repo("locks");
|
||||
// That the variable is *set* on every `Host::git` is asserted once, for
|
||||
// every host, by the conformance suite's `git_optional_locks_env_is_set`
|
||||
// — not re-derived here. What this test owns is the consequence.
|
||||
|
||||
// add — the write the contract flags as the one to prove.
|
||||
let wt = create(&*h, &repo, &req("lock-wt")).unwrap();
|
||||
assert!(wt.path.join("a.txt").exists());
|
||||
|
||||
// list — the new checkout is really registered, not merely on disk.
|
||||
let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap();
|
||||
assert!(
|
||||
list.lines()
|
||||
.any(|l| l.starts_with("branch ") && l.ends_with(&wt.branch)),
|
||||
"worktree list must show the new checkout: {list}"
|
||||
);
|
||||
|
||||
// A commit inside the checkout: an index write, the operation whose
|
||||
// *optional* index refresh is what the variable suppresses.
|
||||
std::fs::write(wt.path.join("c.txt"), "c").unwrap();
|
||||
git(&*h, &wt.path, &["add", "."]).unwrap();
|
||||
git(
|
||||
&*h,
|
||||
&wt.path,
|
||||
&[
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"c",
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), "");
|
||||
|
||||
// remove + `branch -d`: the branch now carries a commit the main branch
|
||||
// does not, so the best-effort `-d` correctly declines and the branch
|
||||
// survives — the safety property `remove` documents.
|
||||
let m = managed(&*h, &wt.path).unwrap();
|
||||
remove(&*h, &m, false).unwrap();
|
||||
assert!(!wt.path.exists());
|
||||
assert!(branch_exists(&*h, &repo, &wt.branch));
|
||||
// …and a branch with nothing unique on it is deleted, so the `-d` is
|
||||
// genuinely running rather than always failing.
|
||||
let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap();
|
||||
let m = managed(&*h, &plain_wt.path).unwrap();
|
||||
remove(&*h, &m, false).unwrap();
|
||||
assert!(!branch_exists(&*h, &repo, &plain_wt.branch));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_detects_live_cwds_inside_the_worktree() {
|
||||
let h = h();
|
||||
let repo = temp_repo("occ");
|
||||
let wt = create(&repo, &req("occ-wt")).unwrap();
|
||||
let wt = create(&*h, &repo, &req("occ-wt")).unwrap();
|
||||
let inside = wt.path.join("deep");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
assert!(occupied(&wt.path, &[repo.clone(), inside]));
|
||||
assert!(occupied(&*h, &wt.path, &[repo.clone(), inside]));
|
||||
// Cwds elsewhere in the repo don't count…
|
||||
assert!(!occupied(&wt.path, &[repo.clone()]));
|
||||
assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo)));
|
||||
// …and neither does a cwd that no longer exists.
|
||||
assert!(!occupied(&wt.path, &[wt.path.join("gone")]));
|
||||
assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")]));
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_outside_a_repo_errors() {
|
||||
let h = h();
|
||||
let plain = scratch("plain");
|
||||
let err = create(&plain, &req("x")).unwrap_err();
|
||||
let err = create(&*h, &plain, &req("x")).unwrap_err();
|
||||
assert_eq!(err, "not inside a git repository");
|
||||
assert_eq!(defaults(&plain).unwrap_err(), "not inside a git repository");
|
||||
assert_eq!(
|
||||
defaults(&*h, &plain).unwrap_err(),
|
||||
"not inside a git repository"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&plain);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
||||
//! [`Duplex`] — one bidirectional link, as the *server* side sees it.
|
||||
//!
|
||||
//! The client half of a control connection takes its two halves separately
|
||||
//! ([`ControlClient::connect`](crate::daemon::control::ControlClient::connect)):
|
||||
//! it is always the side that opened the link, so it already holds whatever it
|
||||
//! opened. The server is handed things — an accepted socket, or a process it was
|
||||
//! `exec`'d into — and has to get two halves *out* of them. That is all this
|
||||
//! trait does.
|
||||
//!
|
||||
//! # Why not `try_clone`
|
||||
//!
|
||||
//! Every existing server path in the tree splits with `try_clone`
|
||||
//! (`daemon::server::handle_conn`), which works because a socket is one object
|
||||
//! with two directions. Under `tty7-server --stdio` it is not: the read half is
|
||||
//! file descriptor 0 and the write half is file descriptor 1, two unrelated
|
||||
//! pipes to two different places. There is nothing to clone. So the split
|
||||
//! happens once, at construction, and the trait says so.
|
||||
//!
|
||||
//! # Shutdown is [`LinkShutdown`], not a second abstraction
|
||||
//!
|
||||
//! A server thread parked in `read` cannot be woken by a flag — it is inside a
|
||||
//! syscall, and the peer has no reason to send anything, because it is waiting
|
||||
//! for a reply. This is precisely the deadlock
|
||||
//! [`LinkShutdown`](crate::daemon::control::LinkShutdown) exists to break on the
|
||||
//! client side, and it is the same deadlock here.
|
||||
//!
|
||||
//! So [`Halves::shutdown`] **is** a `LinkShutdown`, reusing that trait rather
|
||||
//! than mirroring it. Two shutdown abstractions that mean the same thing would
|
||||
//! be two places to forget a transport, and the socket impls would have to be
|
||||
//! written twice; adopting the existing one costs nothing and keeps a single
|
||||
//! answer to "how do I force this link to end".
|
||||
//!
|
||||
//! It is also non-optional. Every transport a server can be handed *has* an
|
||||
//! answer — for a socket it is `shutdown(2)`, for stdio it is closing the write
|
||||
//! half — and making the field an `Option` would only mean the question could be
|
||||
//! skipped, which is how the client-side deadlock happened in the first place.
|
||||
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::daemon::control::LinkShutdown;
|
||||
|
||||
/// The two halves of a link, plus the handle that can force the read half to
|
||||
/// return.
|
||||
pub struct Halves<R, W> {
|
||||
/// Inbound bytes. Read on the connection's own thread.
|
||||
pub read: R,
|
||||
/// Outbound bytes. Shared by every worker replying on this connection, so
|
||||
/// the server keeps it behind a mutex and writes one whole frame per lock.
|
||||
pub write: W,
|
||||
/// How to end the link from another thread. See the module docs.
|
||||
pub shutdown: Arc<dyn LinkShutdown>,
|
||||
}
|
||||
|
||||
/// A bidirectional link a server can serve one connection over.
|
||||
///
|
||||
/// Implemented for the accepted-socket types on both platforms and for
|
||||
/// [`StdioDuplex`]; anything else a future transport brings (an SSH channel,
|
||||
/// say) implements it the same way.
|
||||
pub trait Duplex: Send + 'static {
|
||||
/// The inbound half.
|
||||
type Read: io::Read + Send + 'static;
|
||||
/// The outbound half.
|
||||
type Write: io::Write + Send + 'static;
|
||||
|
||||
/// Consume the link and yield its halves. Consuming rather than borrowing is
|
||||
/// what lets stdio participate: its halves were never one object to begin
|
||||
/// with.
|
||||
fn split(self) -> io::Result<Halves<Self::Read, Self::Write>>;
|
||||
|
||||
/// A short label for logs and diagnostics, so "the link dropped" can say
|
||||
/// *which kind* of link.
|
||||
fn kind_label(&self) -> &'static str;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Duplex for std::os::unix::net::UnixStream {
|
||||
type Read = std::os::unix::net::UnixStream;
|
||||
type Write = std::os::unix::net::UnixStream;
|
||||
|
||||
fn split(self) -> io::Result<Halves<Self::Read, Self::Write>> {
|
||||
let read = self.try_clone()?;
|
||||
let shutdown: Arc<dyn LinkShutdown> = Arc::new(self.try_clone()?);
|
||||
Ok(Halves {
|
||||
read,
|
||||
write: self,
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn kind_label(&self) -> &'static str {
|
||||
"unix"
|
||||
}
|
||||
}
|
||||
|
||||
impl Duplex for std::net::TcpStream {
|
||||
type Read = std::net::TcpStream;
|
||||
type Write = std::net::TcpStream;
|
||||
|
||||
fn split(self) -> io::Result<Halves<Self::Read, Self::Write>> {
|
||||
let read = self.try_clone()?;
|
||||
let shutdown: Arc<dyn LinkShutdown> = Arc::new(self.try_clone()?);
|
||||
Ok(Halves {
|
||||
read,
|
||||
write: self,
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn kind_label(&self) -> &'static str {
|
||||
"tcp"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stdio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The process's own stdin and stdout, as one link.
|
||||
///
|
||||
/// This is how `tty7-server --stdio --serve` is reached: whatever spawned it
|
||||
/// (`ssh host tty7-server --stdio`, `wsl.exe -- tty7-server --stdio`, or a test
|
||||
/// harness) talks to it down the pipes it was born with. There is no socket, no
|
||||
/// port and no filesystem rendezvous — which is the entire point, because that
|
||||
/// is what makes the path work under `AllowStreamLocalForwarding no`, under WSL,
|
||||
/// and in CI on a box with no sshd.
|
||||
///
|
||||
/// Unix only. A Windows `tty7-server` is reached over its own loopback
|
||||
/// transport; nothing in the tree spawns one down a pipe, and the handle
|
||||
/// surgery below has no portable equivalent worth carrying unused.
|
||||
#[cfg(unix)]
|
||||
pub struct StdioDuplex {
|
||||
read: std::fs::File,
|
||||
write: StdioWriter,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl StdioDuplex {
|
||||
/// Take exclusive ownership of the process's stdin and stdout.
|
||||
///
|
||||
/// **This is a hijack, deliberately.** Both descriptors are duplicated, and
|
||||
/// the originals are then pointed at `/dev/null`. After this call `println!`,
|
||||
/// a library's stray progress bar, and anything else that reaches for fd 1
|
||||
/// write into the void instead of into the middle of a control frame. A
|
||||
/// protocol carried on stdout cannot share stdout, and "nothing in this
|
||||
/// process ever prints" is not an invariant that survives a dependency
|
||||
/// bump — so it is enforced here rather than assumed.
|
||||
///
|
||||
/// Diagnostics still work: stderr is untouched, and it is where the stdio
|
||||
/// server logs.
|
||||
pub fn take() -> io::Result<StdioDuplex> {
|
||||
use std::os::fd::FromRawFd as _;
|
||||
|
||||
// Duplicate first, redirect second: if the redirect failed after a
|
||||
// successful dup we would still hold a working link, whereas the other
|
||||
// order could leave the process with no stdout at all.
|
||||
let stdin_fd = dup_fd(libc::STDIN_FILENO)?;
|
||||
let stdout_fd = dup_fd(libc::STDOUT_FILENO)?;
|
||||
redirect_to_null(libc::STDIN_FILENO)?;
|
||||
redirect_to_null(libc::STDOUT_FILENO)?;
|
||||
|
||||
// SAFETY: both fds came from `dup(2)` above, are owned by nobody else,
|
||||
// and are handed to `File` exactly once.
|
||||
let read = unsafe { std::fs::File::from_raw_fd(stdin_fd) };
|
||||
let write = unsafe { std::fs::File::from_raw_fd(stdout_fd) };
|
||||
Ok(StdioDuplex {
|
||||
read,
|
||||
write: StdioWriter {
|
||||
inner: Arc::new(Mutex::new(Some(write))),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Duplex for StdioDuplex {
|
||||
type Read = std::fs::File;
|
||||
type Write = StdioWriter;
|
||||
|
||||
fn split(self) -> io::Result<Halves<Self::Read, Self::Write>> {
|
||||
let shutdown: Arc<dyn LinkShutdown> = Arc::new(self.write.clone());
|
||||
Ok(Halves {
|
||||
read: self.read,
|
||||
write: self.write,
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
fn kind_label(&self) -> &'static str {
|
||||
"stdio"
|
||||
}
|
||||
}
|
||||
|
||||
/// The write half of a [`StdioDuplex`]: a closable stdout.
|
||||
///
|
||||
/// Closing it is the whole reason this type exists rather than a bare `File`.
|
||||
/// The peer of a stdio server has exactly one way to learn the server is
|
||||
/// finished — reading EOF on the pipe — and the only way to produce that EOF is
|
||||
/// to drop the last descriptor on our end. So the file lives behind a shared
|
||||
/// slot that [`LinkShutdown::shutdown_link`] can empty from any thread.
|
||||
///
|
||||
/// The read half is deliberately *not* closable the same way. Closing a pipe
|
||||
/// descriptor another thread is already blocked reading on does not wake it (the
|
||||
/// open file description outlives the descriptor), so pretending otherwise would
|
||||
/// be a shutdown that silently does nothing. What actually ends the read is the
|
||||
/// peer hanging up — which closing our write half is exactly what provokes. It
|
||||
/// is the same half-close a TCP `shutdown(Write)` performs, for the same reason.
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone)]
|
||||
pub struct StdioWriter {
|
||||
inner: Arc<Mutex<Option<std::fs::File>>>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl io::Write for StdioWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let mut slot = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match slot.as_mut() {
|
||||
Some(f) => f.write(buf),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"stdio link was shut down",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let mut slot = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match slot.as_mut() {
|
||||
Some(f) => f.flush(),
|
||||
// Nothing buffered and nothing to flush it to: the shutdown already
|
||||
// happened, and reporting an error here would only turn an orderly
|
||||
// close into a logged failure.
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl LinkShutdown for StdioWriter {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
let taken = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
.is_some();
|
||||
if !taken {
|
||||
// Already closed. Idempotent on purpose: `close` runs from both the
|
||||
// teardown path and a `Drop`, and neither should have to know
|
||||
// whether the other went first.
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn dup_fd(fd: libc::c_int) -> io::Result<libc::c_int> {
|
||||
// SAFETY: `fd` is one of the standard descriptors; `dup` either returns a
|
||||
// fresh owned descriptor or -1 with errno set.
|
||||
let new = unsafe { libc::dup(fd) };
|
||||
if new < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(new)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn redirect_to_null(fd: libc::c_int) -> io::Result<()> {
|
||||
let null = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/null")?;
|
||||
use std::os::fd::AsRawFd as _;
|
||||
// SAFETY: both descriptors are valid and owned here; `dup2` closes `fd`
|
||||
// and re-points it at `/dev/null` atomically.
|
||||
let rc = unsafe { libc::dup2(null.as_raw_fd(), fd) };
|
||||
if rc < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
/// The socket impls hand back halves that really are the same link, and a
|
||||
/// shutdown from a third handle ends a parked read — the property the whole
|
||||
/// trait exists for.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_unix_stream_splits_into_working_halves() {
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (a, b) = UnixStream::pair().unwrap();
|
||||
let Halves {
|
||||
mut read,
|
||||
mut write,
|
||||
shutdown,
|
||||
} = a.split().unwrap();
|
||||
|
||||
let mut peer = b;
|
||||
write.write_all(b"ping").unwrap();
|
||||
write.flush().unwrap();
|
||||
let mut got = [0u8; 4];
|
||||
peer.read_exact(&mut got).unwrap();
|
||||
assert_eq!(&got, b"ping");
|
||||
|
||||
peer.write_all(b"pong").unwrap();
|
||||
let mut got = [0u8; 4];
|
||||
read.read_exact(&mut got).unwrap();
|
||||
assert_eq!(&got, b"pong");
|
||||
|
||||
// The parked reader has to come back, not hang.
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut sink = Vec::new();
|
||||
read.read_to_end(&mut sink).map(|_| ())
|
||||
});
|
||||
shutdown.shutdown_link().unwrap();
|
||||
let _ = reader.join().unwrap();
|
||||
}
|
||||
|
||||
/// Shutting a stdio writer down closes it for good: a later write fails
|
||||
/// rather than quietly succeeding into a descriptor the peer no longer has.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_shut_stdio_writer_refuses_further_writes() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let file = std::fs::File::create(tmp.path()).unwrap();
|
||||
let mut w = StdioWriter {
|
||||
inner: Arc::new(Mutex::new(Some(file))),
|
||||
};
|
||||
w.write_all(b"before").unwrap();
|
||||
w.shutdown_link().unwrap();
|
||||
assert_eq!(
|
||||
w.write(b"after").unwrap_err().kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
);
|
||||
// Idempotent: teardown and `Drop` both call it.
|
||||
w.shutdown_link().unwrap();
|
||||
assert_eq!(std::fs::read(tmp.path()).unwrap(), b"before");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! The pure half of the installer: `uname -sm` → release asset, client version →
|
||||
//! release tag → download URL, and the remote paths a server binary lives at.
|
||||
//!
|
||||
//! Everything here is a total function of its arguments — no network, no SFTP, no
|
||||
//! clock — which is the point: [`docs/remote-server-assets.md`] is a *literal*
|
||||
//! contract with the release workflow, and a contract is only worth having if
|
||||
//! both sides can be tested without standing up the other one.
|
||||
//!
|
||||
//! [`docs/remote-server-assets.md`]: ../../../../../docs/remote-server-assets.md
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// The release asset for a 64-bit x86 Linux box.
|
||||
pub const ASSET_X86_64: &str = "tty7-server-x86_64-unknown-linux-musl";
|
||||
/// The release asset for a 64-bit ARM Linux box.
|
||||
pub const ASSET_AARCH64: &str = "tty7-server-aarch64-unknown-linux-musl";
|
||||
/// The sha256 manifest published beside every asset in a release.
|
||||
pub const CHECKSUMS_ASSET: &str = "checksums.txt";
|
||||
|
||||
/// Where release assets are downloaded from. The tag and asset name are appended
|
||||
/// (`{RELEASE_BASE}/{tag}/{asset}`); HTTPS to github.com is the trust anchor for
|
||||
/// the checksum file itself (§16).
|
||||
pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download";
|
||||
|
||||
/// The `XDG_DATA_HOME`-shaped directory tty7 owns on a remote machine, relative
|
||||
/// to `$HOME`. Split into components because the installer has to `mkdir` each
|
||||
/// level (SFTP has no `mkdir -p`) and because joining is `/`-only regardless of
|
||||
/// the *client's* OS — a Windows client must not produce `.local\share`
|
||||
/// (contract §4.3).
|
||||
pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"];
|
||||
|
||||
/// Why a machine cannot be served a `tty7-server`.
|
||||
///
|
||||
/// Both variants carry the raw `uname -sm` output: the whole value of refusing
|
||||
/// instead of guessing is that the user can read the string we refused and either
|
||||
/// recognise their box or paste it into an issue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UnsupportedTarget {
|
||||
/// `uname -s` is not `Linux`. A remote tty7-server is a Linux binary; there
|
||||
/// is no macOS/BSD/Solaris asset to fall back to.
|
||||
NotLinux { raw: String },
|
||||
/// `uname -s` is `Linux` but `uname -m` is not one we publish for — 32-bit
|
||||
/// arm, i686, riscv64, or something we have never seen.
|
||||
UnknownMachine { raw: String },
|
||||
/// `uname -sm` did not produce the two whitespace-separated words it is
|
||||
/// specified to. Almost always means the command did not run at all (a login
|
||||
/// shell that printed a banner, a restricted shell) rather than a real
|
||||
/// answer, so it gets its own variant with the raw text.
|
||||
Unparseable { raw: String },
|
||||
}
|
||||
|
||||
impl UnsupportedTarget {
|
||||
/// The `uname -sm` text this refusal is about, as the remote printed it.
|
||||
pub fn raw(&self) -> &str {
|
||||
match self {
|
||||
Self::NotLinux { raw } | Self::UnknownMachine { raw } | Self::Unparseable { raw } => {
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UnsupportedTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotLinux { raw } => write!(
|
||||
f,
|
||||
"a remote tty7 workspace needs a Linux host; this machine reports `uname -sm` = {raw:?}"
|
||||
),
|
||||
Self::UnknownMachine { raw } => write!(
|
||||
f,
|
||||
"no tty7-server is published for this architecture (`uname -sm` = {raw:?}); \
|
||||
supported: x86_64/amd64 and aarch64/arm64"
|
||||
),
|
||||
Self::Unparseable { raw } => write!(
|
||||
f,
|
||||
"`uname -sm` did not answer with a system and machine name (got {raw:?})"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UnsupportedTarget {}
|
||||
|
||||
/// Map raw `uname -sm` output to the release asset that runs on that machine.
|
||||
///
|
||||
/// **Exact string match, then fail.** No prefix matching, no "starts with `arm`
|
||||
/// so it is probably aarch64" heuristic. Guessing wrong here installs a binary
|
||||
/// that dies with `Exec format error` at first exec — an error with no visible
|
||||
/// connection to the architecture detection that caused it, on a machine the user
|
||||
/// may not be able to inspect. An unknown machine string is a clean, explainable
|
||||
/// refusal that names itself (`docs/remote-server-assets.md`).
|
||||
///
|
||||
/// `amd64` / `arm64` are accepted alongside the values Linux actually reports
|
||||
/// because some container images and BSD-flavoured userlands normalise to them.
|
||||
pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget> {
|
||||
let raw = uname_sm.trim().to_string();
|
||||
let mut words = raw.split_whitespace();
|
||||
let (Some(system), Some(machine), None) = (words.next(), words.next(), words.next()) else {
|
||||
return Err(UnsupportedTarget::Unparseable { raw });
|
||||
};
|
||||
if system != "Linux" {
|
||||
return Err(UnsupportedTarget::NotLinux { raw });
|
||||
}
|
||||
match machine {
|
||||
"x86_64" | "amd64" => Ok(ASSET_X86_64),
|
||||
"aarch64" | "arm64" | "armv8l" | "armv8b" => Ok(ASSET_AARCH64),
|
||||
_ => Err(UnsupportedTarget::UnknownMachine { raw }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an asset name that arrived over a wire back to a `&'static str`.
|
||||
///
|
||||
/// [`super::InstallRequest::asset`] is `&'static str` because on the producing
|
||||
/// side it is always one of the two consts above. A decoder cannot promise that,
|
||||
/// and the relay in `daemon::router` has to rebuild the request a *different
|
||||
/// process* raised — so the two known names map to themselves, and anything else
|
||||
/// (a client older or newer than the daemon that named it) is leaked.
|
||||
///
|
||||
/// Leaking is bounded in the way that matters: the value comes from tty7's own
|
||||
/// daemon naming one of its own release assets, and a session sees at most a
|
||||
/// handful of distinct machines. It is preferred to guessing one of the two
|
||||
/// consts, which would show the user a prompt naming the wrong architecture.
|
||||
pub fn interned(name: &str) -> &'static str {
|
||||
if name == ASSET_X86_64 {
|
||||
ASSET_X86_64
|
||||
} else if name == ASSET_AARCH64 {
|
||||
ASSET_AARCH64
|
||||
} else {
|
||||
Box::leak(name.to_string().into_boxed_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// The release tag whose assets a client of `version` must download.
|
||||
///
|
||||
/// The nightly channel republishes a single rolling `nightly` tag every night, so
|
||||
/// a nightly client must not ask for `v26.7.6-nightly.20260727` — that tag does
|
||||
/// not exist and never will. Rule: the version contains `-nightly.` → `nightly`;
|
||||
/// otherwise `v` + version.
|
||||
pub fn release_tag(version: &str) -> String {
|
||||
if version.contains("-nightly.") {
|
||||
"nightly".to_string()
|
||||
} else {
|
||||
format!("v{version}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The download URL for one asset of one release.
|
||||
pub fn download_url(tag: &str, asset: &str) -> String {
|
||||
format!("{RELEASE_BASE}/{tag}/{asset}")
|
||||
}
|
||||
|
||||
/// Absolute remote paths for one client version's server binary.
|
||||
///
|
||||
/// Built with explicit `/` joins from an absolute `$HOME` the remote resolved for
|
||||
/// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows
|
||||
/// client — contract §4.3).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePaths {
|
||||
/// `$HOME/.local/share/tty7/bin`.
|
||||
pub bin_dir: String,
|
||||
/// `$HOME/.local/share/tty7/bin/tty7-server-<version>` — the atomically
|
||||
/// published binary. The version is *in the path* so two clients of different
|
||||
/// versions can coexist on one machine; only the running daemon is singular.
|
||||
pub binary: String,
|
||||
/// `$HOME/.local/share/tty7/bin/.tty7-server-<version>.tmp` — where the bytes
|
||||
/// land before `chmod` + `rename`.
|
||||
///
|
||||
/// A dotfile, so a half-written upload is not mistaken for an installed
|
||||
/// server by anything globbing the directory, and a *fixed* name per version
|
||||
/// so an install killed mid-upload leaves one reusable file behind instead of
|
||||
/// accumulating random-suffixed litter on someone else's disk.
|
||||
pub temp: String,
|
||||
/// Every directory that must exist before the upload, outermost first. SFTP
|
||||
/// has no recursive mkdir, so the installer walks this.
|
||||
pub dir_chain: Vec<String>,
|
||||
}
|
||||
|
||||
/// Build the remote paths for `version` under an absolute remote `home`.
|
||||
pub fn remote_paths(home: &str, version: &str) -> RemotePaths {
|
||||
let home = home.trim_end_matches('/');
|
||||
let mut dir_chain = Vec::with_capacity(INSTALL_DIR_COMPONENTS.len());
|
||||
let mut cursor = home.to_string();
|
||||
for part in INSTALL_DIR_COMPONENTS {
|
||||
cursor = format!("{cursor}/{part}");
|
||||
dir_chain.push(cursor.clone());
|
||||
}
|
||||
let bin_dir = cursor;
|
||||
RemotePaths {
|
||||
binary: format!("{bin_dir}/{}", binary_name(version)),
|
||||
temp: format!("{bin_dir}/.tty7-server-{version}.tmp"),
|
||||
dir_chain,
|
||||
bin_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// The filename a `version`'s server binary is installed under.
|
||||
pub fn binary_name(version: &str) -> String {
|
||||
format!("tty7-server-{version}")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// install path carries the version by construction, so `readlink /proc/<pid>/exe`
|
||||
/// on the remote answers "which tty7-server is serving this machine" for every
|
||||
/// build we have ever shipped — including ones older than any handshake we could
|
||||
/// send them.
|
||||
pub fn version_from_path(path: &str) -> Option<String> {
|
||||
let name = path.rsplit('/').next()?;
|
||||
let rest = name.strip_prefix("tty7-server-")?;
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(rest.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The contract's mapping table, row for row. This test *is* the client half
|
||||
/// of `docs/remote-server-assets.md`: if the release workflow ever renames an
|
||||
/// asset, this is where the two sides stop agreeing.
|
||||
#[test]
|
||||
fn uname_maps_to_the_published_assets() {
|
||||
for raw in ["Linux x86_64", "Linux amd64"] {
|
||||
assert_eq!(asset_for_uname(raw).unwrap(), ASSET_X86_64, "{raw}");
|
||||
}
|
||||
for raw in [
|
||||
"Linux aarch64",
|
||||
"Linux arm64",
|
||||
"Linux armv8l",
|
||||
"Linux armv8b",
|
||||
] {
|
||||
assert_eq!(asset_for_uname(raw).unwrap(), ASSET_AARCH64, "{raw}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Real `uname` output ends in a newline, and a shell may pad it. Trimming is
|
||||
/// the only normalisation allowed — the *words* are matched exactly.
|
||||
#[test]
|
||||
fn uname_output_is_trimmed_before_matching() {
|
||||
assert_eq!(asset_for_uname("Linux x86_64\n").unwrap(), ASSET_X86_64);
|
||||
assert_eq!(
|
||||
asset_for_uname(" Linux x86_64 \r\n").unwrap(),
|
||||
ASSET_X86_64
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal path, which is the whole reason this function exists. Every
|
||||
/// one of these would be a plausible prefix/fuzzy match — `x86_64-v2` starts
|
||||
/// with `x86_64`, `armv7l` starts with `arm`, `Linux` appears inside
|
||||
/// `GNU/Linux` — and each would install a binary that cannot exec.
|
||||
#[test]
|
||||
fn unknown_machines_are_refused_not_guessed() {
|
||||
for raw in [
|
||||
"Linux i686",
|
||||
"Linux i386",
|
||||
"Linux armv7l",
|
||||
"Linux armv6l",
|
||||
"Linux riscv64",
|
||||
"Linux ppc64le",
|
||||
"Linux s390x",
|
||||
"Linux x86_64-v2",
|
||||
"Linux aarch64_be",
|
||||
"Linux ARM64",
|
||||
"Linux X86_64",
|
||||
] {
|
||||
let err = asset_for_uname(raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, UnsupportedTarget::UnknownMachine { .. }),
|
||||
"{raw} must be refused as an unknown machine, got {err:?}"
|
||||
);
|
||||
assert_eq!(err.raw(), raw, "the refusal must quote what it refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Linux host is refused with its own variant so the message can say
|
||||
/// "needs Linux" rather than "unknown architecture" — the user's next step is
|
||||
/// completely different.
|
||||
#[test]
|
||||
fn non_linux_systems_are_refused() {
|
||||
for raw in [
|
||||
"Darwin arm64",
|
||||
"FreeBSD amd64",
|
||||
"SunOS i86pc",
|
||||
"linux x86_64",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
asset_for_uname(raw).unwrap_err(),
|
||||
UnsupportedTarget::NotLinux { .. }
|
||||
),
|
||||
"{raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that is not exactly two words never reaches the mapping. In
|
||||
/// practice this catches the common failure where the command did not run and
|
||||
/// we got a shell banner, an error message, or nothing at all — and it is the
|
||||
/// guard that keeps a three-word string from silently matching on its first
|
||||
/// two words.
|
||||
#[test]
|
||||
fn output_that_is_not_two_words_is_unparseable() {
|
||||
for raw in [
|
||||
"",
|
||||
" ",
|
||||
"Linux",
|
||||
"x86_64",
|
||||
"Linux x86_64 GNU/Linux",
|
||||
"bash: uname: command not found",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
asset_for_uname(raw).unwrap_err(),
|
||||
UnsupportedTarget::Unparseable { .. }
|
||||
),
|
||||
"{raw:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable releases resolve to their own tag; nightlies resolve to the single
|
||||
/// rolling `nightly` tag, because per-night tags are never created.
|
||||
#[test]
|
||||
fn release_tag_sends_nightlies_to_the_rolling_tag() {
|
||||
assert_eq!(release_tag("26.7.5"), "v26.7.5");
|
||||
assert_eq!(release_tag("0.1.0"), "v0.1.0");
|
||||
assert_eq!(release_tag("26.7.6-nightly.20260727"), "nightly");
|
||||
// A pre-release that is *not* a nightly keeps its own tag: only the
|
||||
// nightly channel republishes under a rolling name.
|
||||
assert_eq!(release_tag("26.8.0-rc.1"), "v26.8.0-rc.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_urls_point_at_the_release_the_tag_names() {
|
||||
assert_eq!(
|
||||
download_url(&release_tag("26.7.5"), ASSET_X86_64),
|
||||
"https://github.com/l0ng-ai/tty7/releases/download/v26.7.5/tty7-server-x86_64-unknown-linux-musl"
|
||||
);
|
||||
assert_eq!(
|
||||
download_url(&release_tag("26.7.6-nightly.20260727"), CHECKSUMS_ASSET),
|
||||
"https://github.com/l0ng-ai/tty7/releases/download/nightly/checksums.txt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Path construction, including the `mkdir` chain. Asserted literally: these
|
||||
/// strings are what an SFTP server sees, and a `\` in any of them (which is
|
||||
/// what `PathBuf::join` would produce on a Windows client) would create a file
|
||||
/// named `.local\share\tty7\bin` in the remote home directory.
|
||||
#[test]
|
||||
fn remote_paths_are_posix_and_versioned() {
|
||||
let p = remote_paths("/home/me", "26.7.5");
|
||||
assert_eq!(p.bin_dir, "/home/me/.local/share/tty7/bin");
|
||||
assert_eq!(
|
||||
p.binary,
|
||||
"/home/me/.local/share/tty7/bin/tty7-server-26.7.5"
|
||||
);
|
||||
assert_eq!(
|
||||
p.temp,
|
||||
"/home/me/.local/share/tty7/bin/.tty7-server-26.7.5.tmp"
|
||||
);
|
||||
assert_eq!(
|
||||
p.dir_chain,
|
||||
vec![
|
||||
"/home/me/.local",
|
||||
"/home/me/.local/share",
|
||||
"/home/me/.local/share/tty7",
|
||||
"/home/me/.local/share/tty7/bin",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!p.temp.contains('\\') && !p.binary.contains('\\'),
|
||||
"remote paths are POSIX regardless of the client's OS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The temp name is a sibling dotfile of the target, so the finishing rename
|
||||
/// is same-directory (same filesystem → atomic) and a partial upload is not
|
||||
/// mistaken for an installed server.
|
||||
#[test]
|
||||
fn temp_path_is_a_hidden_sibling_of_the_binary() {
|
||||
let p = remote_paths("/home/me", "26.7.5");
|
||||
let dir = |s: &str| s.rsplit_once('/').unwrap().0.to_string();
|
||||
assert_eq!(dir(&p.temp), dir(&p.binary));
|
||||
assert!(p.temp.rsplit('/').next().unwrap().starts_with('.'));
|
||||
assert!(!p.binary.rsplit('/').next().unwrap().starts_with('.'));
|
||||
}
|
||||
|
||||
/// A trailing slash on the resolved home (some SFTP servers return `/root/`)
|
||||
/// must not produce a doubled separator.
|
||||
#[test]
|
||||
fn trailing_slash_on_home_is_absorbed() {
|
||||
assert_eq!(
|
||||
remote_paths("/root/", "1.0.0").binary,
|
||||
"/root/.local/share/tty7/bin/tty7-server-1.0.0"
|
||||
);
|
||||
// Root as home is degenerate but must still be well-formed.
|
||||
assert_eq!(remote_paths("/", "1.0.0").bin_dir, "/.local/share/tty7/bin");
|
||||
}
|
||||
|
||||
/// The inverse used to identify a *running* daemon from its executable path.
|
||||
#[test]
|
||||
fn version_is_recoverable_from_an_install_path() {
|
||||
assert_eq!(
|
||||
version_from_path("/home/me/.local/share/tty7/bin/tty7-server-26.7.4").as_deref(),
|
||||
Some("26.7.4")
|
||||
);
|
||||
assert_eq!(
|
||||
version_from_path("tty7-server-26.7.6-nightly.20260727").as_deref(),
|
||||
Some("26.7.6-nightly.20260727")
|
||||
);
|
||||
// Not ours, or not versioned: no opinion rather than a wrong one.
|
||||
assert_eq!(version_from_path("/usr/bin/tty7-server"), None);
|
||||
assert_eq!(version_from_path("/usr/local/bin/tty7-server-"), None);
|
||||
assert_eq!(version_from_path("/bin/bash"), None);
|
||||
}
|
||||
|
||||
/// Round-trip: the name we install under is the name we recognise later.
|
||||
#[test]
|
||||
fn install_path_and_version_extraction_round_trip() {
|
||||
for version in ["26.7.5", "0.1.0", "26.7.6-nightly.20260727"] {
|
||||
let p = remote_paths("/home/me", version);
|
||||
assert_eq!(version_from_path(&p.binary).as_deref(), Some(version));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! `checksums.txt` parsing and asset verification (§16).
|
||||
//!
|
||||
//! The release publishes one GNU coreutils `sha256sum`-format manifest covering
|
||||
//! every asset. HTTPS to github.com is the trust anchor — the manifest is not
|
||||
//! separately signed — so this module's whole job is to make sure the bytes we
|
||||
//! are about to write onto someone else's machine are the bytes that release
|
||||
//! actually published.
|
||||
//!
|
||||
//! Pure: no network, no filesystem. The bytes come in as a slice.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
/// A parsed sha256 digest: 32 raw bytes, compared by value rather than by
|
||||
/// string so casing and whitespace can never make a comparison accidentally
|
||||
/// succeed.
|
||||
pub type Digest = [u8; 32];
|
||||
|
||||
/// Why an asset failed verification. Every variant aborts the install; none of
|
||||
/// them is retried, and there is no unverified fallback (§17).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChecksumError {
|
||||
/// The manifest has no line for this asset. Either the release is
|
||||
/// incomplete or we derived an asset name the release does not carry — both
|
||||
/// are "stop", never "install it anyway".
|
||||
Missing { asset: String },
|
||||
/// The asset's line exists but is not `<64 hex> <name>`.
|
||||
Malformed { asset: String, line: String },
|
||||
/// The manifest and the downloaded bytes disagree. The one variant that can
|
||||
/// mean something is actively wrong (a corrupted download, a proxy that
|
||||
/// rewrote the body, a compromised mirror), so it reports both digests.
|
||||
Mismatch {
|
||||
asset: String,
|
||||
expected: String,
|
||||
actual: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for ChecksumError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Missing { asset } => write!(
|
||||
f,
|
||||
"checksums.txt has no entry for {asset}; refusing to install an unverified binary"
|
||||
),
|
||||
Self::Malformed { asset, line } => write!(
|
||||
f,
|
||||
"checksums.txt entry for {asset} is malformed ({line:?}); \
|
||||
refusing to install an unverified binary"
|
||||
),
|
||||
Self::Mismatch {
|
||||
asset,
|
||||
expected,
|
||||
actual,
|
||||
} => write!(
|
||||
f,
|
||||
"{asset} failed sha256 verification: release says {expected}, downloaded bytes are \
|
||||
{actual}. Install aborted; nothing was written to the remote machine"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChecksumError {}
|
||||
|
||||
/// The sha256 of some bytes.
|
||||
pub fn sha256(bytes: &[u8]) -> Digest {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Render a digest as lowercase hex, for messages.
|
||||
pub fn hex(digest: &Digest) -> String {
|
||||
use fmt::Write as _;
|
||||
digest.iter().fold(String::with_capacity(64), |mut s, b| {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse 64 hex characters into a digest. Case-insensitive (§16 step 3); any
|
||||
/// other length or a non-hex character is a parse failure.
|
||||
fn parse_hex(s: &str) -> Option<Digest> {
|
||||
if s.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The digest `manifest` records for `asset`.
|
||||
///
|
||||
/// **The filename field is matched whole, never by substring** (§16 step 2).
|
||||
/// `tty7-server-x86_64-unknown-linux-musl` happens not to be a substring of any
|
||||
/// other asset today, but that is an accident of the current release contents,
|
||||
/// not a property anyone maintains — and a substring match that drifted would
|
||||
/// silently verify one binary's bytes against another's digest.
|
||||
///
|
||||
/// The coreutils format is `<digest><two spaces><name>`; the second space is `*`
|
||||
/// in binary mode (`<digest> *<name>`), which some tools emit, so a leading `*`
|
||||
/// on the name is stripped. Blank lines and `#` comments are skipped.
|
||||
pub fn expected_digest(manifest: &str, asset: &str) -> Result<Digest, ChecksumError> {
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line.trim().is_empty() || line.trim_start().starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Split once on whitespace: everything before is the digest field,
|
||||
// everything after (minus the binary-mode marker) is the filename field.
|
||||
let Some((digest_field, name_field)) = line.split_once(char::is_whitespace) else {
|
||||
continue;
|
||||
};
|
||||
let name = name_field.trim_start().trim_start_matches('*');
|
||||
if name != asset {
|
||||
continue;
|
||||
}
|
||||
return parse_hex(digest_field).ok_or_else(|| ChecksumError::Malformed {
|
||||
asset: asset.to_string(),
|
||||
line: line.to_string(),
|
||||
});
|
||||
}
|
||||
Err(ChecksumError::Missing {
|
||||
asset: asset.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify downloaded `bytes` against the manifest. `Ok(())` is the only outcome
|
||||
/// that permits an install.
|
||||
pub fn verify(manifest: &str, asset: &str, bytes: &[u8]) -> Result<(), ChecksumError> {
|
||||
let expected = expected_digest(manifest, asset)?;
|
||||
let actual = sha256(bytes);
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ChecksumError::Mismatch {
|
||||
asset: asset.to_string(),
|
||||
expected: hex(&expected),
|
||||
actual: hex(&actual),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::daemon::install::asset::{ASSET_AARCH64, ASSET_X86_64};
|
||||
|
||||
/// A manifest shaped exactly like the release workflow's, with digests that
|
||||
/// really are the sha256 of the payloads below.
|
||||
fn manifest_for(payloads: &[(&str, &[u8])]) -> String {
|
||||
payloads
|
||||
.iter()
|
||||
.map(|(name, bytes)| format!("{} {name}\n", hex(&sha256(bytes))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_bytes_verify() {
|
||||
let bytes = b"\x7fELF pretend this is a server".as_slice();
|
||||
let manifest = manifest_for(&[(ASSET_X86_64, bytes), (ASSET_AARCH64, b"other")]);
|
||||
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).
|
||||
#[test]
|
||||
fn digest_comparison_is_case_insensitive() {
|
||||
let bytes = b"payload".as_slice();
|
||||
// Only the digest is uppercased — the filename field stays exact-match.
|
||||
let manifest = format!("{} {ASSET_X86_64}\n", hex(&sha256(bytes)).to_uppercase());
|
||||
verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter");
|
||||
}
|
||||
|
||||
/// **The failure path §18 names.** Bytes that do not match must abort with
|
||||
/// both digests reported — not retry, not install anyway.
|
||||
#[test]
|
||||
fn mismatched_bytes_abort_with_both_digests() {
|
||||
let published = b"the real server binary".as_slice();
|
||||
let tampered = b"the real server binary!".as_slice();
|
||||
let manifest = manifest_for(&[(ASSET_X86_64, published)]);
|
||||
|
||||
let err = verify(&manifest, ASSET_X86_64, tampered).unwrap_err();
|
||||
match err {
|
||||
ChecksumError::Mismatch {
|
||||
ref asset,
|
||||
ref expected,
|
||||
ref actual,
|
||||
} => {
|
||||
assert_eq!(asset, ASSET_X86_64);
|
||||
assert_eq!(*expected, hex(&sha256(published)));
|
||||
assert_eq!(*actual, hex(&sha256(tampered)));
|
||||
assert_ne!(expected, actual);
|
||||
}
|
||||
other => panic!("a mismatch must report both digests, got {other:?}"),
|
||||
}
|
||||
// The message has to be actionable on its own — it is what the user sees.
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("sha256"), "{msg}");
|
||||
assert!(msg.contains("aborted"), "{msg}");
|
||||
}
|
||||
|
||||
/// A one-bit difference is caught. Cheap to assert, and the property the
|
||||
/// whole verification exists for.
|
||||
#[test]
|
||||
fn a_single_flipped_bit_fails() {
|
||||
let mut payload = vec![0u8; 4096];
|
||||
payload[1234] = 0x5a;
|
||||
let manifest = manifest_for(&[(ASSET_X86_64, &payload)]);
|
||||
let mut flipped = payload.clone();
|
||||
flipped[1234] ^= 0x01;
|
||||
assert!(matches!(
|
||||
verify(&manifest, ASSET_X86_64, &flipped),
|
||||
Err(ChecksumError::Mismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// No line for our asset → abort. This is the "release is missing the
|
||||
/// architecture we need" case, and installing the other architecture (or
|
||||
/// nothing-checked) would both be worse than stopping.
|
||||
#[test]
|
||||
fn a_missing_entry_aborts() {
|
||||
let manifest = manifest_for(&[(ASSET_AARCH64, b"arm bytes")]);
|
||||
assert!(matches!(
|
||||
verify(&manifest, ASSET_X86_64, b"anything"),
|
||||
Err(ChecksumError::Missing { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
verify("", ASSET_X86_64, b"anything"),
|
||||
Err(ChecksumError::Missing { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// A line whose digest field is not 64 hex characters is malformed, not
|
||||
/// "close enough". Truncated digests are exactly what a partially-uploaded
|
||||
/// manifest looks like.
|
||||
#[test]
|
||||
fn a_malformed_entry_aborts() {
|
||||
for bad in [
|
||||
"abc tty7-server-x86_64-unknown-linux-musl",
|
||||
"zz786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1x tty7-server-x86_64-unknown-linux-musl",
|
||||
" tty7-server-x86_64-unknown-linux-musl",
|
||||
] {
|
||||
let err = expected_digest(bad, ASSET_X86_64).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ChecksumError::Malformed { .. } | ChecksumError::Missing { .. }
|
||||
),
|
||||
"{bad:?} produced {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whole-field match, not substring.** A manifest carrying a longer name
|
||||
/// that *contains* ours must not satisfy the lookup — this is the guard §16
|
||||
/// step 2 asks for.
|
||||
#[test]
|
||||
fn filename_matching_is_exact_not_substring() {
|
||||
let payload = b"decoy".as_slice();
|
||||
let manifest = format!(
|
||||
"{} {ASSET_X86_64}.sig\n{} old-{ASSET_X86_64}\n",
|
||||
hex(&sha256(payload)),
|
||||
hex(&sha256(payload)),
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
expected_digest(&manifest, ASSET_X86_64),
|
||||
Err(ChecksumError::Missing { .. })
|
||||
),
|
||||
"neither a suffixed nor a prefixed name may satisfy the lookup"
|
||||
);
|
||||
}
|
||||
|
||||
/// Binary-mode (`*name`) lines, CRLF line endings, comments and blank lines
|
||||
/// are all shapes a checksum file can legitimately arrive in.
|
||||
#[test]
|
||||
fn tolerates_binary_mode_crlf_and_comments() {
|
||||
let payload = b"payload".as_slice();
|
||||
let digest = hex(&sha256(payload));
|
||||
let manifest =
|
||||
format!("# generated by the release workflow\r\n\r\n{digest} *{ASSET_X86_64}\r\n");
|
||||
verify(&manifest, ASSET_X86_64, payload).expect("binary-mode CRLF lines must parse");
|
||||
}
|
||||
|
||||
/// Empty input hashes to the well-known empty sha256; a fixed vector keeps
|
||||
/// the hashing itself honest rather than only self-consistent.
|
||||
#[test]
|
||||
fn sha256_matches_known_vectors() {
|
||||
assert_eq!(
|
||||
hex(&sha256(b"")),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&sha256(b"abc")),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! The HTTPS half of D5: the *client* downloads release assets and pushes them
|
||||
//! over SSH, because the machines this feature exists for — behind a jump host,
|
||||
//! on an internal network, in a locked-down VPC — frequently cannot reach GitHub
|
||||
//! themselves.
|
||||
//!
|
||||
//! ## Why `ureq`, and why behind a feature
|
||||
//!
|
||||
//! The GUI's update check uses `reqwest_client`, which wraps Zed's reqwest fork
|
||||
//! behind `gpui::http_client`. `tty7-core` must not depend on gpui, so that stack
|
||||
//! is unavailable here. `ureq` is blocking (which matches this call path — the
|
||||
//! installer runs on a daemon std thread, not in an async context), rustls-based
|
||||
//! (no OpenSSL, so nothing to find at build time), and shares the `rustls` and
|
||||
//! `http` versions already in the tree.
|
||||
//!
|
||||
//! It is optional, behind `remote-install`, which the GUI crate turns on and
|
||||
//! `tty7-server` does not. `tty7-server` builds as a *static musl* binary that is
|
||||
//! itself the thing being downloaded; giving it an HTTP client would add size and
|
||||
//! a TLS backend to every remote install for a code path it can never take. Same
|
||||
//! mechanism as the existing `gssapi` feature, for the same reason.
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::AssetFetcher;
|
||||
|
||||
/// Overall budget for one asset download. A 6 MB binary on a bad connection is
|
||||
/// slow but finite; a stalled TLS session is not, and this is what makes the
|
||||
/// difference visible.
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Refuse a body larger than this. A release asset is ~6 MB; anything at this
|
||||
/// scale means we are downloading something other than what we asked for, and
|
||||
/// buffering it in memory before finding out is not a good trade.
|
||||
const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Downloads release assets over HTTPS.
|
||||
pub struct HttpsFetcher {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl Default for HttpsFetcher {
|
||||
fn default() -> Self {
|
||||
let config = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(DOWNLOAD_TIMEOUT))
|
||||
.user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION")))
|
||||
.build();
|
||||
Self {
|
||||
agent: config.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetFetcher for HttpsFetcher {
|
||||
fn get(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||
let response = self
|
||||
.agent
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| describe(url, &e.to_string()))?;
|
||||
|
||||
// GitHub serves release assets from a redirect to object storage; ureq
|
||||
// follows those itself. A 404 here is the interesting one: it means the
|
||||
// release tag we derived from our own version was never published (a
|
||||
// local dev build, a tag that failed to publish), and saying so beats
|
||||
// "download failed".
|
||||
let status = response.status().as_u16();
|
||||
if status == 404 {
|
||||
return Err(format!(
|
||||
"{url} does not exist (404) — this build's release may not be published"
|
||||
));
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("{url} returned HTTP {status}"));
|
||||
}
|
||||
|
||||
let mut body = response.into_body();
|
||||
let mut bytes = Vec::new();
|
||||
body.as_reader()
|
||||
.take(MAX_ASSET_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| describe(url, &e.to_string()))?;
|
||||
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
||||
return Err(format!(
|
||||
"{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset"
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a transport error into something a user can act on. The distinction
|
||||
/// worth drawing is "the network is not reachable from here" (retry later, or
|
||||
/// check the proxy) versus everything else.
|
||||
fn describe(url: &str, reason: &str) -> String {
|
||||
let lower = reason.to_ascii_lowercase();
|
||||
if lower.contains("dns") || lower.contains("resolve") {
|
||||
return format!("could not resolve the host for {url} ({reason})");
|
||||
}
|
||||
if lower.contains("certificate") || lower.contains("tls") || lower.contains("handshake") {
|
||||
return format!(
|
||||
"TLS failed fetching {url} ({reason}) — a TLS-intercepting proxy would explain this"
|
||||
);
|
||||
}
|
||||
reason.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Constructing the agent must not panic (a rustls provider that fails to
|
||||
/// install would, and would do it at the worst possible moment — mid-connect
|
||||
/// on someone's first remote workspace).
|
||||
#[test]
|
||||
fn the_agent_builds() {
|
||||
let _ = HttpsFetcher::default();
|
||||
}
|
||||
|
||||
/// Talks to the real github.com. `#[ignore]`d because it needs the network,
|
||||
/// which no other test here does — run it by hand (`cargo test -p tty7-core
|
||||
/// --features remote-install -- --ignored talks_to_github --nocapture`)
|
||||
/// after touching the HTTP client.
|
||||
///
|
||||
/// Two things only a live server can prove:
|
||||
///
|
||||
/// - **Redirects are followed.** Every GitHub download path — `/raw/` and
|
||||
/// `releases/download/…` alike — answers with a 302 to another host. A
|
||||
/// client that does not follow it returns an empty body under a status
|
||||
/// that still reads as success, so the "asset" would sha256 to the digest
|
||||
/// of nothing. Asserting real content is what catches that.
|
||||
/// - **The TLS trust anchor works.** ureq's webpki roots must accept
|
||||
/// github.com's chain; that HTTPS connection *is* the security model here
|
||||
/// (§16 — `checksums.txt` is not separately signed).
|
||||
///
|
||||
/// Deliberately a small file rather than a release asset: assets are ~20 MB
|
||||
/// and this is a correctness check, not a bandwidth test.
|
||||
#[test]
|
||||
#[ignore = "needs the network"]
|
||||
fn talks_to_github() {
|
||||
let fetcher = HttpsFetcher::default();
|
||||
let bytes = fetcher
|
||||
.get("https://github.com/l0ng-ai/tty7/raw/main/README.md")
|
||||
.expect("github must be reachable over TLS");
|
||||
assert!(
|
||||
bytes.len() > 500,
|
||||
"got {} bytes — an unfollowed redirect looks exactly like this",
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
// And a tag that was never published is reported as such, not as a
|
||||
// generic transport failure.
|
||||
let missing = super::super::asset::download_url("v0.0.0-never", "tty7-server-nope");
|
||||
let err = fetcher
|
||||
.get(&missing)
|
||||
.expect_err("a missing release must fail");
|
||||
assert!(err.contains("404"), "{err}");
|
||||
}
|
||||
|
||||
/// The proxy/TLS case gets its own wording because the fix is completely
|
||||
/// different from "try again later".
|
||||
#[test]
|
||||
fn tls_failures_name_the_likely_cause() {
|
||||
let msg = describe("https://example/x", "invalid peer certificate");
|
||||
assert!(msg.contains("proxy"), "{msg}");
|
||||
let msg = describe("https://example/x", "failed to lookup address information");
|
||||
assert!(!msg.contains("proxy"), "{msg}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
//! [`RemoteOps`] over a live [`SshConnection`]: command execution on a session
|
||||
//! channel, file manipulation over SFTP.
|
||||
//!
|
||||
//! This is the only file in `install` that talks to a network. Everything it
|
||||
//! does is a thin, synchronous wrapper — the installer above it is a state
|
||||
//! machine, and keeping the IO down here as dumb as possible is what lets that
|
||||
//! state machine be tested against a fake.
|
||||
//!
|
||||
//! ## Why the SFTP work is split
|
||||
//!
|
||||
//! `stat` / `mkdir` / `chmod` / `rename` / `remove` / `list` all go through
|
||||
//! [`SftpManager`], which owns one cached SFTP session per connection — the
|
||||
//! installer costs no extra channel for them. The **byte write** does not:
|
||||
//! `SftpManager::start_transfer` is the wrong upload path for an install: it is
|
||||
//! a background job keyed by `pane_id` that reads from a local *file* and
|
||||
//! reports progress to the GUI's transfer tray, and an install has no pane, no
|
||||
//! local file (the bytes are in memory, already verified) and nothing to show
|
||||
//! in a tray. [`SftpManager::put_bytes`] exists for exactly this shape, so the
|
||||
//! write shares the connection's cached SFTP session — and its
|
||||
//! retry-once-on-transport-failure behaviour — rather than opening a channel of
|
||||
//! its own.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use russh::ChannelMsg;
|
||||
|
||||
use crate::daemon::protocol::{SftpOp, SftpOpResult};
|
||||
use crate::daemon::ssh::{SshConnection, SshManager, sftp::SftpManager};
|
||||
|
||||
use super::{ExecOutput, RemoteOps, RemoteStat};
|
||||
|
||||
/// How long any single remote command may take. Generous: `uname` is instant,
|
||||
/// but the daemon probe opens a socket on a machine that may be busy, and a
|
||||
/// distant host's round trips add up. Short enough that a hung sshd surfaces as
|
||||
/// an error rather than as a connect that never returns.
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Budget for the fire-and-forget daemon launch. The remote shell backgrounds
|
||||
/// the daemon and exits immediately, so this only has to cover a round trip.
|
||||
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// [`RemoteOps`] backed by one authenticated SSH connection.
|
||||
pub struct SshRemoteOps {
|
||||
conn: Arc<SshConnection>,
|
||||
}
|
||||
|
||||
impl SshRemoteOps {
|
||||
pub fn new(conn: Arc<SshConnection>) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
/// Run one SFTP op through the shared, cached session.
|
||||
fn sftp_op(&self, op: SftpOp) -> Result<SftpOpResult, String> {
|
||||
match SftpManager::global().op(&self.conn, &op) {
|
||||
SftpOpResult::Error(e) => Err(e),
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn block_on<T>(&self, fut: impl Future<Output = T>) -> T {
|
||||
SshManager::global().handle().block_on(fut)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteOps for SshRemoteOps {
|
||||
fn home_dir(&self) -> Result<String, String> {
|
||||
// SFTP's REALPATH against the session's own working directory, which is
|
||||
// the login directory. The same trick the file browser uses to open
|
||||
// somewhere better than `/`, and the only way to learn `$HOME` without
|
||||
// trusting a shell to have one set.
|
||||
match self.sftp_op(SftpOp::Realpath {
|
||||
path: ".".to_string(),
|
||||
})? {
|
||||
SftpOpResult::Link(path) if path.starts_with('/') => Ok(path),
|
||||
SftpOpResult::Link(path) => Err(format!(
|
||||
"the remote resolved its home to {path:?}, which is not absolute"
|
||||
)),
|
||||
other => Err(format!(
|
||||
"unexpected SFTP reply resolving the home directory: {other:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, cmd: &str) -> Result<ExecOutput, String> {
|
||||
let conn = self.conn.clone();
|
||||
let cmd = cmd.to_string();
|
||||
self.block_on(async move {
|
||||
match tokio::time::timeout(COMMAND_TIMEOUT, exec(&conn, &cmd)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!(
|
||||
"the remote did not finish `{cmd}` within {COMMAND_TIMEOUT:?}"
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_detached(&self, cmd: &str) -> Result<(), String> {
|
||||
let conn = self.conn.clone();
|
||||
let cmd = cmd.to_string();
|
||||
// The remote shell backgrounds the process and exits, so this *is* a
|
||||
// normal exec — only the budget differs. Its exit status is ignored on
|
||||
// purpose: `sh -c '... &'` reports on the backgrounding, never on the
|
||||
// daemon, and whether the daemon really came up is settled by probing
|
||||
// its socket, not by trusting a shell.
|
||||
self.block_on(async move {
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, exec(&conn, &cmd)).await {
|
||||
Ok(Ok(_)) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(format!(
|
||||
"the remote did not accept the daemon launch within {LAUNCH_TIMEOUT:?}"
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn stat(&self, path: &str) -> Result<Option<RemoteStat>, String> {
|
||||
match self.sftp_op(SftpOp::Stat {
|
||||
path: path.to_string(),
|
||||
}) {
|
||||
Ok(SftpOpResult::Stat(entry)) => Ok(Some(RemoteStat {
|
||||
size: entry.size,
|
||||
mode: entry.permissions,
|
||||
is_dir: entry.kind == crate::daemon::protocol::SftpEntryKind::Dir,
|
||||
})),
|
||||
Ok(other) => Err(format!("unexpected SFTP reply for stat: {other:?}")),
|
||||
// "Not there" is an answer, not a failure — it is the *expected*
|
||||
// answer on the first install, and turning it into an error would
|
||||
// make step 2 unable to say "go install it".
|
||||
Err(e) if is_not_found(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn mkdir(&self, path: &str) -> Result<(), String> {
|
||||
match self.sftp_op(SftpOp::Mkdir {
|
||||
path: path.to_string(),
|
||||
}) {
|
||||
Ok(_) => Ok(()),
|
||||
// Servers disagree about which status an existing directory gets
|
||||
// (`Failure`, `PermissionDenied`, a bare "file already exists"), so
|
||||
// the authority on "does it exist" is a stat, not the error text.
|
||||
Err(e) => match self.stat(path) {
|
||||
Ok(Some(stat)) if stat.is_dir => Ok(()),
|
||||
_ => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn chmod(&self, path: &str, mode: u32) -> Result<(), String> {
|
||||
self.sftp_op(SftpOp::Chmod {
|
||||
path: path.to_string(),
|
||||
mode,
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
SftpManager::global().put_bytes(&self.conn, path, bytes)
|
||||
}
|
||||
|
||||
fn rename(&self, from: &str, to: &str) -> Result<(), String> {
|
||||
self.sftp_op(SftpOp::Rename {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn remove_file(&self, path: &str) -> Result<(), String> {
|
||||
self.sftp_op(SftpOp::RemoveFile {
|
||||
path: path.to_string(),
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn list_dir(&self, path: &str) -> Result<Option<Vec<String>>, String> {
|
||||
match SftpManager::global().list(&self.conn, path) {
|
||||
Ok(entries) => Ok(Some(entries.into_iter().map(|e| e.name).collect())),
|
||||
Err(e) if is_not_found(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stringified SFTP error means "no such file", which every caller
|
||||
/// here treats as a normal answer rather than a failure.
|
||||
///
|
||||
/// russh-sftp renders a server status as `<code>: <message>`; the message text
|
||||
/// is the server's, so this matches on the shapes OpenSSH and the common
|
||||
/// non-OpenSSH servers produce rather than on a code we cannot see.
|
||||
fn is_not_found(msg: &str) -> bool {
|
||||
let lower = msg.to_ascii_lowercase();
|
||||
lower.contains("no such file")
|
||||
|| lower.contains("nosuchfile")
|
||||
|| lower.contains("not found")
|
||||
|| lower.contains("does not exist")
|
||||
}
|
||||
|
||||
/// Run one command on its own session channel and collect everything it said.
|
||||
///
|
||||
/// Loops until `wait()` returns `None` rather than breaking on `Eof`/`Close`:
|
||||
/// the exit status arrives as its own message and can follow both, and the exit
|
||||
/// status is the entire point of the daemon probe.
|
||||
async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String> {
|
||||
let mut channel = conn
|
||||
.open_session_channel()
|
||||
.await
|
||||
.map_err(|e| format!("could not open a command channel: {e}"))?;
|
||||
channel
|
||||
.exec(true, cmd)
|
||||
.await
|
||||
.map_err(|e| format!("could not run `{cmd}`: {e}"))?;
|
||||
// Close our end of the command's stdin immediately. Nothing here writes to
|
||||
// a command, and the daemon probe specifically relies on its stdin ending
|
||||
// so the bridge it starts hangs up instead of parking forever.
|
||||
let _ = channel.eof().await;
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let mut status = None;
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
|
||||
ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
|
||||
ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExecOutput {
|
||||
status,
|
||||
stdout: String::from_utf8_lossy(&stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The "absent" classification has to hold for the wordings the servers we
|
||||
/// meet actually use, because step 2's whole decision ("is the right version
|
||||
/// already installed?") rests on it — and misreading a missing file as an
|
||||
/// error would turn every first install into a hard failure.
|
||||
#[test]
|
||||
fn missing_files_are_recognised_across_server_wordings() {
|
||||
for msg in [
|
||||
"2: No such file",
|
||||
"No such file or directory",
|
||||
"NoSuchFile",
|
||||
"file not found",
|
||||
"The system cannot find the path specified: does not exist",
|
||||
] {
|
||||
assert!(is_not_found(msg), "{msg:?} means absent");
|
||||
}
|
||||
}
|
||||
|
||||
/// And must not swallow the failures §17 requires to be reported: a full
|
||||
/// disk or a read-only home has to surface as an error with a path, never as
|
||||
/// "the file isn't there, go ahead and install".
|
||||
#[test]
|
||||
fn real_failures_are_not_mistaken_for_absence() {
|
||||
for msg in [
|
||||
"3: Permission denied",
|
||||
"4: Failure",
|
||||
"no space left on device",
|
||||
"disk quota exceeded",
|
||||
"connection reset by peer",
|
||||
] {
|
||||
assert!(!is_not_found(msg), "{msg:?} is a failure, not an absence");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,9 @@
|
||||
//!
|
||||
//! Layout:
|
||||
//! - [`protocol`] — the framed wire messages shared by client and daemon.
|
||||
//! - [`control`] — the *control* dialect: the same framing, but multiplexed by
|
||||
//! request id, carrying the filesystem/git RPCs a remote workspace runs
|
||||
//! against a machine that isn't this one.
|
||||
//! - [`transport`] — the cross-platform local stream the protocol rides on
|
||||
//! (Unix-domain socket on Unix, loopback TCP on Windows).
|
||||
//! - `pane` (daemon side) — owns one PTY/child, a replay ring, and fan-out.
|
||||
@@ -21,11 +24,16 @@
|
||||
//! `terminal::remote::RemoteTerminal`, exposing the same surface as the old
|
||||
//! in-process `Terminal` so the view layer is largely unchanged.
|
||||
|
||||
pub mod control;
|
||||
pub mod duplex;
|
||||
pub mod install;
|
||||
pub mod pane;
|
||||
pub mod pidfile;
|
||||
pub mod procinfo;
|
||||
pub mod protocol;
|
||||
pub(crate) mod remote;
|
||||
pub mod remote_link;
|
||||
pub mod router;
|
||||
pub mod server;
|
||||
pub mod spawn;
|
||||
/// Native (russh) SSH session engine — see the module docs.
|
||||
@@ -51,21 +51,64 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
|
||||
///
|
||||
/// ## History
|
||||
///
|
||||
/// - **v3** — the [`control`](super::control) dialect (kinds 60-63) and
|
||||
/// [`DaemonVersion::features`]. By the rule above this is *additive* and
|
||||
/// would not earn a bump on its own: a v2 daemon meeting a control frame
|
||||
/// already reports an unknown kind. The bump buys something else — it makes
|
||||
/// "does this peer speak control?" a question that can be asked **forwards**.
|
||||
/// Without it the only probe is to open a control connection and see whether
|
||||
/// the peer drops it, which costs a round trip, logs a misleading desync
|
||||
/// error, and is indistinguishable from a genuine desync. `features` then
|
||||
/// makes this the *last* bump of its kind: further capabilities are announced
|
||||
/// as strings, not as a higher number.
|
||||
/// - **v2** — [`RemoteKind::Wsl`]. A v1 client decoding a WSL pane's
|
||||
/// `RemoteContext` errors out and loses the pane, which only bites on a
|
||||
/// downgrade (a v2 GUI spawns the pane, a v1 GUI later attaches to it), but
|
||||
/// loses it silently. The handshake now catches that skew and asks.
|
||||
/// - **v1** — the dialect at the time versioning landed.
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
pub const PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Reply to `ClientMsg::Version`: the protocol dialect the daemon speaks, plus
|
||||
/// its crate version for logs/diagnostics. Only `protocol` drives decisions.
|
||||
/// its crate version for logs/diagnostics. Only `protocol` and `features` drive
|
||||
/// decisions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DaemonVersion {
|
||||
pub protocol: u32,
|
||||
/// The daemon binary's `CARGO_PKG_VERSION`. Display only.
|
||||
#[serde(default)]
|
||||
pub build: String,
|
||||
/// Fine-grained capability bits — see [`super::control::feature`].
|
||||
///
|
||||
/// `#[serde(default)]`, so a pre-v3 daemon's reply still decodes (as an
|
||||
/// empty list, which is the truth about it). This exists so that a
|
||||
/// capability added after v3 does **not** need another version bump, and
|
||||
/// therefore does not need to provoke the "Restart Daemon?" prompt for
|
||||
/// every user whose daemon happens to predate it.
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
impl DaemonVersion {
|
||||
/// What *this* build answers with.
|
||||
///
|
||||
/// A single constructor so the capability list can't drift between the
|
||||
/// daemon's reply and anything else that claims to describe this build.
|
||||
pub fn current() -> DaemonVersion {
|
||||
DaemonVersion {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
build: env!("CARGO_PKG_VERSION").to_string(),
|
||||
// The local session daemon speaks the pane protocol only. The
|
||||
// control dialect is served by `tty7-server`, which advertises
|
||||
// `control` / `host-rpc` itself; claiming them here would make the
|
||||
// GUI open a control connection this process cannot answer.
|
||||
features: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this peer advertises `name`.
|
||||
pub fn has_feature(&self, name: &str) -> bool {
|
||||
self.features.iter().any(|f| f == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal geometry shared by spawn/attach/resize. Cell pixel size travels too
|
||||
@@ -106,7 +149,7 @@ pub struct ShellSpec {
|
||||
/// Whether a short `ssh` option flag consumes the following argument as its
|
||||
/// value. Used by the GUI's typed-connect parser to skip an option's value while
|
||||
/// hunting for the destination token.
|
||||
pub(crate) fn ssh_option_takes_value(flag: char) -> bool {
|
||||
pub fn ssh_option_takes_value(flag: char) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
'B' | 'b'
|
||||
@@ -192,6 +235,72 @@ pub struct LoopbackForwardRequest {
|
||||
pub remote_port: u16,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace-scoped control requests (design §15, M7).
|
||||
//
|
||||
// A *remote workspace* has no pane on this daemon: its panes live on the remote
|
||||
// `tty7-server`, and the only thing this side owns is the `SshConnection` the
|
||||
// workspace's routed link rides. So every pane-addressed control request above
|
||||
// (`SftpList { pane_id }`, `AddForward { pane_id }`, …) is unaddressable for it.
|
||||
//
|
||||
// Rather than a parallel variant per operation, the workspace form is one
|
||||
// envelope: the *only* thing that differs is how the connection is found, and
|
||||
// the daemon resolves that once, up front. Replies reuse the existing
|
||||
// `DaemonMsg` variants verbatim, so no daemon-space kind is spent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A control request that runs on a **remote workspace's** SSH connection
|
||||
/// rather than a pane's.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceRequest {
|
||||
/// Which workspace the request is *attributed* to. Two workspaces on the
|
||||
/// same machine share one `SshConnection` but own their forwards
|
||||
/// separately, so this is not derivable from `spec`.
|
||||
pub workspace: crate::core::session::WorkspaceId,
|
||||
/// Names the machine. **Secret-free** ([`NativeSshSpec::without_secrets`]):
|
||||
/// the daemon only ever *looks up* an already-authenticated connection with
|
||||
/// this key and never connects, so nothing here needs to authenticate.
|
||||
pub spec: Box<NativeSshSpec>,
|
||||
/// The pane the caller is rendering the answer under, stamped into
|
||||
/// `ManagedForward::pane_id` / `SftpJobProgress::pane_id` so the GUI's
|
||||
/// per-pane panels can filter rows they asked for. Display only — it is
|
||||
/// *not* what the forward is owned by, and a workspace forward outlives it.
|
||||
pub view_pane: u64,
|
||||
pub op: WorkspaceOp,
|
||||
}
|
||||
|
||||
/// The operation half of a [`WorkspaceRequest`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum WorkspaceOp {
|
||||
/// ⌘/Ctrl-clicked `localhost:PORT` — ensure an on-demand local forward to
|
||||
/// `remote_host:remote_port` and reply `LoopbackForward { local_port }`.
|
||||
EnsureLoopback {
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
},
|
||||
/// Establish a managed forward owned by the workspace; replies `ForwardList`.
|
||||
AddForward { rule: SshForwardRule },
|
||||
/// Tear one workspace forward down by id; replies `ForwardList`.
|
||||
RemoveForward { forward_id: u64 },
|
||||
/// The workspace's managed forwards; replies `ForwardList`.
|
||||
ListForwards,
|
||||
/// Drop every forward the workspace owns (the workspace was closed). Replies
|
||||
/// with the — now empty — `ForwardList`.
|
||||
TeardownForwards,
|
||||
/// List a remote directory over the workspace's SFTP session; replies
|
||||
/// `SftpEntries`.
|
||||
SftpList { path: String },
|
||||
/// A one-shot SFTP operation on the workspace's session; replies
|
||||
/// `SftpOpResult`.
|
||||
SftpOp { op: SftpOp },
|
||||
/// Start an upload/download on the workspace's session; replies
|
||||
/// `SftpTransferStarted`. `spec.pane_id` is ignored in favour of `view_pane`.
|
||||
SftpTransferStart { spec: SftpTransferSpec },
|
||||
/// Poll the workspace's transfer jobs; replies `SftpTransferProgress`.
|
||||
SftpTransferList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoopbackForward {
|
||||
pub local_port: u16,
|
||||
@@ -881,6 +990,9 @@ pub enum ClientMsg {
|
||||
/// panel's Info tab is open. Pushing it on a timer would burn that cost for
|
||||
/// every pane, forever, to feed a view that's usually closed.
|
||||
QueryProcs { pane_id: u64 },
|
||||
/// A control request scoped to a **remote workspace's** SSH connection
|
||||
/// instead of a pane's (design §15). See [`WorkspaceRequest`].
|
||||
OnWorkspace(Box<WorkspaceRequest>),
|
||||
/// Ask which protocol version the daemon speaks (control connection); the
|
||||
/// daemon replies `Version`. A daemon that predates versioning doesn't know
|
||||
/// this kind and drops the connection instead of replying — the client
|
||||
@@ -1021,6 +1133,15 @@ mod kind {
|
||||
/// `QueryProcs` — a pane's process tree + listening ports, for the details
|
||||
/// panel. 50 sits clear of every range above and of `VERSION`.
|
||||
pub const QUERY_PROCS: u8 = 50;
|
||||
// 51 is taken: `daemon::router::ROUTE_KIND`, the route header that hands a
|
||||
// connection to a remote `tty7-server`. It is defined there rather than
|
||||
// here because this module is private and the router must not become a
|
||||
// reason to open it — but the number is spent either way.
|
||||
/// `OnWorkspace` — a control request on a remote workspace's SSH connection
|
||||
/// (design §15). 52 is the next number clear of every range above, of the
|
||||
/// router's 51, and of the retired 13; the contract's control connection
|
||||
/// reserves 60-63, which this stays below.
|
||||
pub const ON_WORKSPACE: u8 = 52;
|
||||
|
||||
// Daemon -> client
|
||||
pub const SPAWNED: u8 = 1;
|
||||
@@ -1205,6 +1326,7 @@ impl ClientMsg {
|
||||
ClientMsg::ListForwards { pane_id } => {
|
||||
write_frame(w, kind::LIST_FORWARDS, &to_json(pane_id)?)
|
||||
}
|
||||
ClientMsg::OnWorkspace(req) => write_frame(w, kind::ON_WORKSPACE, &to_json(req)?),
|
||||
ClientMsg::Version => write_frame(w, kind::VERSION, &[]),
|
||||
}
|
||||
}
|
||||
@@ -1284,6 +1406,7 @@ impl ClientMsg {
|
||||
kind::LIST_FORWARDS => ClientMsg::ListForwards {
|
||||
pane_id: from_json(&payload)?,
|
||||
},
|
||||
kind::ON_WORKSPACE => ClientMsg::OnWorkspace(from_json(&payload)?),
|
||||
kind::VERSION => ClientMsg::Version,
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
@@ -1775,6 +1898,12 @@ mod tests {
|
||||
DaemonMsg::Version(DaemonVersion {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
build: "0.15.0".into(),
|
||||
features: vec!["control".into(), "host-rpc".into()],
|
||||
}),
|
||||
DaemonMsg::Version(DaemonVersion {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
build: "0.15.0".into(),
|
||||
features: Vec::new(),
|
||||
}),
|
||||
DaemonMsg::Error("nope".into()),
|
||||
];
|
||||
@@ -2108,6 +2237,69 @@ mod tests {
|
||||
assert_eq!(clean.login_script, vec!["tmux attach".to_string()]);
|
||||
}
|
||||
|
||||
/// Every `WorkspaceOp` round-trips inside the `OnWorkspace` envelope. Kept
|
||||
/// as its own test rather than folded into `client_roundtrip` so the
|
||||
/// pane-addressed corpus there stays byte-for-byte what it was.
|
||||
#[test]
|
||||
fn on_workspace_roundtrip() {
|
||||
let ws = crate::core::session::WorkspaceId::new();
|
||||
let spec = Box::new(sample_native_spec().without_secrets());
|
||||
let ops = vec![
|
||||
WorkspaceOp::EnsureLoopback {
|
||||
remote_host: "127.0.0.1".into(),
|
||||
remote_port: 3000,
|
||||
},
|
||||
WorkspaceOp::AddForward {
|
||||
rule: SshForwardRule {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: 0,
|
||||
target_host: "127.0.0.1".into(),
|
||||
target_port: 5432,
|
||||
description: Some("db".into()),
|
||||
},
|
||||
},
|
||||
WorkspaceOp::RemoveForward { forward_id: 4 },
|
||||
WorkspaceOp::ListForwards,
|
||||
WorkspaceOp::TeardownForwards,
|
||||
WorkspaceOp::SftpList {
|
||||
path: "/home/me".into(),
|
||||
},
|
||||
WorkspaceOp::SftpOp {
|
||||
op: SftpOp::Realpath { path: ".".into() },
|
||||
},
|
||||
WorkspaceOp::SftpTransferStart {
|
||||
spec: SftpTransferSpec {
|
||||
pane_id: 0,
|
||||
kind: SftpTransferKind::Download,
|
||||
local: PathBuf::from("/local/f"),
|
||||
remote: "/remote/f".into(),
|
||||
recursive: false,
|
||||
},
|
||||
},
|
||||
WorkspaceOp::SftpTransferList,
|
||||
];
|
||||
let msgs: Vec<ClientMsg> = ops
|
||||
.into_iter()
|
||||
.map(|op| {
|
||||
ClientMsg::OnWorkspace(Box::new(WorkspaceRequest {
|
||||
workspace: ws,
|
||||
spec: spec.clone(),
|
||||
view_pane: 12,
|
||||
op,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
let mut buf = Vec::new();
|
||||
for m in &msgs {
|
||||
m.encode(&mut buf).unwrap();
|
||||
}
|
||||
let mut cursor = std::io::Cursor::new(buf);
|
||||
for m in &msgs {
|
||||
assert_eq!(*m, ClientMsg::read(&mut cursor).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
/// The new native-SSH client/daemon message variants round-trip through the
|
||||
/// frame codec (new kind bytes included).
|
||||
#[test]
|
||||
@@ -2191,4 +2383,47 @@ mod tests {
|
||||
let (k, _) = read_frame(&mut std::io::Cursor::new(&buf)).unwrap();
|
||||
assert_eq!(k, kind::SPAWN_NATIVE_SSH);
|
||||
}
|
||||
|
||||
/// A daemon that predates `features` answers without the field, and that
|
||||
/// reply must still decode — as an empty capability list, which is exactly
|
||||
/// the truth about it. If it didn't, upgrading the app while an old daemon
|
||||
/// held live sessions would turn the version handshake into a hard failure
|
||||
/// instead of the keep-or-restart question it is meant to be.
|
||||
#[test]
|
||||
fn a_version_reply_without_features_still_decodes() {
|
||||
let legacy = br#"{"protocol":2,"build":"26.7.4"}"#;
|
||||
let v: DaemonVersion = serde_json::from_slice(legacy).unwrap();
|
||||
assert_eq!(v.protocol, 2);
|
||||
assert_eq!(v.build, "26.7.4");
|
||||
assert!(v.features.is_empty());
|
||||
assert!(!v.has_feature(crate::daemon::control::feature::CONTROL));
|
||||
}
|
||||
|
||||
/// And the reverse skew: a *newer* daemon's extra field must not break an
|
||||
/// older client's decode. serde ignores unknown fields by default and this
|
||||
/// struct must never opt out of that, or every future capability becomes a
|
||||
/// breaking change for clients that don't care about it.
|
||||
#[test]
|
||||
fn a_version_reply_with_unknown_fields_still_decodes() {
|
||||
let future = br#"{"protocol":4,"build":"99.0.0","features":["control"],
|
||||
"something_new":{"a":1}}"#;
|
||||
let v: DaemonVersion = serde_json::from_slice(future).unwrap();
|
||||
assert_eq!(v.protocol, 4);
|
||||
assert!(v.has_feature(crate::daemon::control::feature::CONTROL));
|
||||
}
|
||||
|
||||
/// This build's own answer: the bumped version, and — deliberately — no
|
||||
/// control capability, because the *local session daemon* does not serve
|
||||
/// the control dialect. `tty7-server` does, and advertises it itself.
|
||||
/// Claiming it here would make the GUI open a connection this process
|
||||
/// cannot answer.
|
||||
#[test]
|
||||
fn the_local_daemon_does_not_claim_the_control_dialect() {
|
||||
let v = DaemonVersion::current();
|
||||
assert_eq!(v.protocol, 3);
|
||||
assert!(
|
||||
!v.has_feature(crate::daemon::control::feature::CONTROL),
|
||||
"the session daemon must not advertise a dialect it cannot serve"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
//! [`RemoteLink`] — one logical byte stream from the local daemon to a remote
|
||||
//! `tty7-server`.
|
||||
//!
|
||||
//! ## Where this sits, and why it is not in the GUI
|
||||
//!
|
||||
//! The design's "one more transport shape doesn't disturb the layers above" is
|
||||
//! true, but not for the reason it looks like. It is *not* that
|
||||
//! [`crate::daemon::transport::Stream`] grew a variant — that type is a plain
|
||||
//! alias (`UnixStream` on Unix, loopback `TcpStream` on Windows) and it does not
|
||||
//! change by a byte here. It is that **a remote stream never reaches it**:
|
||||
//!
|
||||
//! ```text
|
||||
//! GUI ──transport::Stream (unchanged)──▶ local daemon
|
||||
//! │
|
||||
//! RemoteLink ──▶ SSH channel / WSL stdio
|
||||
//! ```
|
||||
//!
|
||||
//! The GUI still talks to a socket on this machine. The local daemon forwards
|
||||
//! those bytes onto a `RemoteLink` without parsing them — which is what keeps
|
||||
//! the router a router, and keeps the remote version handshake genuinely
|
||||
//! end-to-end between the GUI and the remote server rather than something the
|
||||
//! daemon in the middle has to understand.
|
||||
//!
|
||||
//! Every existing `transport::Stream` call site — `try_clone`, `set_read_timeout`,
|
||||
//! `shutdown(Shutdown::Write)` — is therefore untouched, because every one of
|
||||
//! them is on a stream that is still local.
|
||||
//!
|
||||
//! ## Why an enum, and why four variants over two types
|
||||
//!
|
||||
//! An enum rather than `Box<dyn AsyncRead + AsyncWrite>`, matching
|
||||
//! [`super::ssh::connect::Transport`]: each variant's poll methods are a direct
|
||||
//! delegate with no vtable, on a path that carries every byte of every remote
|
||||
//! pane's output.
|
||||
//!
|
||||
//! Four variants over two underlying types, because the pairs are
|
||||
//! distinguishable only by **how they were obtained**, and that distinction is
|
||||
//! exactly what diagnostics need:
|
||||
//!
|
||||
//! | Variant | Underlying | Distinct because |
|
||||
//! |---|---|---|
|
||||
//! | [`RemoteLink::StreamLocal`] | SSH channel | The preferred path. A failure here is what triggers the one-time fallback probe |
|
||||
//! | [`RemoteLink::SessionExec`] | SSH channel | Already the fallback. A failure here means the remote is genuinely unreachable, not that forwarding is disabled |
|
||||
//! | [`RemoteLink::Wsl`] | child stdio | No SSH involved; auth and host-key problems are impossible by construction |
|
||||
//! | [`RemoteLink::LocalStdio`] | child stdio | A test harness. Must never be mistaken for a real remote in a log |
|
||||
//!
|
||||
//! Collapsing each pair would turn "`AllowStreamLocalForwarding` is off, fall
|
||||
//! back" into an indistinguishable "the connection dropped".
|
||||
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::process::Stdio;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use russh::Channel;
|
||||
use russh::client::Msg;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
|
||||
use super::ssh::ProcessStream;
|
||||
|
||||
/// One logical stream between the local daemon and a remote `tty7-server`.
|
||||
pub enum RemoteLink {
|
||||
/// Preferred: `direct-streamlocal@openssh.com` straight to the remote's
|
||||
/// `daemon.sock`, opened with russh's
|
||||
/// `client::Handle::channel_open_direct_streamlocal`. No extra process on
|
||||
/// the remote, and the remote server's own accept loop handles it exactly
|
||||
/// as it would a local connection.
|
||||
StreamLocal(russh::ChannelStream<russh::client::Msg>),
|
||||
|
||||
/// Fallback for `AllowStreamLocalForwarding no`: a session channel running
|
||||
/// `tty7-server --stdio`, which bridges its own stdin/stdout to that same
|
||||
/// socket. Same type as [`RemoteLink::StreamLocal`], different meaning.
|
||||
SessionExec(russh::ChannelStream<russh::client::Msg>),
|
||||
|
||||
/// WSL, which has no SSH at all: `wsl.exe -d <distro> -- tty7-server --stdio`.
|
||||
Wsl(ProcessStream),
|
||||
|
||||
/// A `tty7-server --stdio` child on *this* machine. The end-to-end test
|
||||
/// path — the one that lets the whole remote stack be exercised in CI with
|
||||
/// no second machine, no SSH daemon, and no credentials.
|
||||
LocalStdio(ProcessStream),
|
||||
}
|
||||
|
||||
impl RemoteLink {
|
||||
/// Adopt a `direct-streamlocal@openssh.com` channel as the preferred link.
|
||||
///
|
||||
/// Taking the [`Channel`] rather than its stream keeps the "which SSH
|
||||
/// primitive opened this" decision at the call site that made it, which is
|
||||
/// the only place that still knows.
|
||||
pub fn stream_local(channel: Channel<Msg>) -> RemoteLink {
|
||||
RemoteLink::StreamLocal(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Adopt a session channel already running `tty7-server --stdio` as the
|
||||
/// fallback link.
|
||||
pub fn session_exec(channel: Channel<Msg>) -> RemoteLink {
|
||||
RemoteLink::SessionExec(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Spawn `program args…` and take its stdio as a [`RemoteLink::LocalStdio`].
|
||||
///
|
||||
/// `kill_on_drop`, so dropping the link reaps the child rather than leaving
|
||||
/// a `tty7-server` parented to a test that has already finished.
|
||||
pub fn local_stdio(program: &str, args: &[&str]) -> io::Result<RemoteLink> {
|
||||
Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// name: `wsl.exe` runs the command without a login shell, so the `PATH`
|
||||
/// that would find `~/.local/share/tty7/bin` is not in effect.
|
||||
/// [`install::wsl::ensure_wsl_server`](crate::daemon::install::wsl::ensure_wsl_server)
|
||||
/// is what resolves it.
|
||||
///
|
||||
/// No shell is involved, so `server` needs no quoting; the distro name is
|
||||
/// validated because it is an *option's* argument and a leading `-` would be
|
||||
/// read as another option.
|
||||
pub fn wsl(distro: &str, server: &str) -> io::Result<RemoteLink> {
|
||||
super::install::wsl::validate_distro(distro)?;
|
||||
let args = super::install::wsl::wsl_args(distro, &[server, "--stdio"]);
|
||||
Ok(RemoteLink::Wsl(spawn_stdio_owned(
|
||||
super::install::wsl::WSL_EXE,
|
||||
&args,
|
||||
)?))
|
||||
}
|
||||
|
||||
/// [`RemoteLink::wsl`] with the command given as a shell string rather than
|
||||
/// a resolved path — the WSL reading of
|
||||
/// [`RouteHeader::server_command`](super::router::RouteHeader::server_command),
|
||||
/// which over SSH is likewise handed to a shell.
|
||||
///
|
||||
/// The escape hatch for a distribution where the normal install path cannot
|
||||
/// be used; the resolved-path form above is what ships.
|
||||
pub fn wsl_shell(distro: &str, command: &str) -> io::Result<RemoteLink> {
|
||||
super::install::wsl::validate_distro(distro)?;
|
||||
let args = super::install::wsl::wsl_args(distro, &["sh", "-c", command]);
|
||||
Ok(RemoteLink::Wsl(spawn_stdio_owned(
|
||||
super::install::wsl::WSL_EXE,
|
||||
&args,
|
||||
)?))
|
||||
}
|
||||
|
||||
/// The label this link goes into logs and the status line under.
|
||||
///
|
||||
/// The whole reason the variants are not collapsed: an operator reading
|
||||
/// "streamlocal" versus "session-exec" in a log knows immediately whether
|
||||
/// the remote refused socket forwarding or whether the box is simply gone.
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
RemoteLink::StreamLocal(_) => "streamlocal",
|
||||
RemoteLink::SessionExec(_) => "session-exec",
|
||||
RemoteLink::Wsl(_) => "wsl-stdio",
|
||||
RemoteLink::LocalStdio(_) => "local-stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this link is a `--stdio` bridge rather than a direct socket.
|
||||
///
|
||||
/// The bridge costs one extra process on the remote and cannot report a
|
||||
/// connection refusal as precisely, so a caller deciding whether to retry
|
||||
/// the preferred path wants to know.
|
||||
pub fn is_stdio_bridge(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
RemoteLink::SessionExec(_) | RemoteLink::Wsl(_) | RemoteLink::LocalStdio(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the link rides an SSH channel (as opposed to a child process).
|
||||
pub fn is_ssh(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
RemoteLink::StreamLocal(_) | RemoteLink::SessionExec(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stdio(program: &str, args: &[&str]) -> io::Result<ProcessStream> {
|
||||
let owned: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
|
||||
spawn_stdio_owned(program, &owned)
|
||||
}
|
||||
|
||||
fn spawn_stdio_owned(program: &str, args: &[String]) -> io::Result<ProcessStream> {
|
||||
let mut command = tokio::process::Command::new(program);
|
||||
// A GUI process spawning `wsl.exe` would otherwise flash a console window
|
||||
// per pane. No-op off Windows.
|
||||
crate::core::proc::hide_console_tokio(&mut command);
|
||||
let mut child = command
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
// stderr stays inherited: the remote server's diagnostics belong in the
|
||||
// daemon's log, and capturing them into a pipe nobody drains would
|
||||
// eventually block the child on a full buffer.
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other(format!("{program} stdin unavailable")))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other(format!("{program} stdout unavailable")))?;
|
||||
Ok(ProcessStream::from_parts(child, stdin, stdout))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// How a host is entered: the one-time decision behind `StreamLocal` vs `SessionExec`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The command run on a session channel when socket forwarding is unavailable.
|
||||
///
|
||||
/// `--stdio` with neither `--serve` nor `--bridge` lets the *remote* decide:
|
||||
/// it bridges to a running daemon if there is one and serves in-process if
|
||||
/// there is not, which is the right answer in both cases and one this side has
|
||||
/// no way to know.
|
||||
///
|
||||
/// **Only a fallback for links that skip the install pass.** SSH links do not:
|
||||
/// `SshManager::open_remote_link` runs `install::ensure_remote_server` first and
|
||||
/// uses the absolute, version-qualified path it returns. That matters because
|
||||
/// nothing puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the
|
||||
/// file there is `tty7-server-<version>` — this bare name would be a
|
||||
/// `command not found` on a machine the install had just succeeded on.
|
||||
/// [`super::router::RouteHeader::server_command`] overrides either.
|
||||
pub const DEFAULT_REMOTE_SERVER_CMD: &str = "tty7-server --stdio";
|
||||
|
||||
/// `sockaddr_un.sun_path` is 104 bytes on macOS and 108 on Linux, NUL included.
|
||||
/// The remote server stays under the smaller figure
|
||||
/// (`host::server`'s `MAX_SOCKET_PATH_BYTES`), so the path derived here must use
|
||||
/// the same bound or the two sides would disagree about when the fallback name
|
||||
/// kicks in.
|
||||
const MAX_SOCKET_PATH_BYTES: usize = 100;
|
||||
|
||||
/// How this connection reaches the remote `tty7-server` — decided once per SSH
|
||||
/// connection and cached there (design §7.1), never re-decided per channel.
|
||||
///
|
||||
/// Probing per channel would put a failed `direct-streamlocal` open in front of
|
||||
/// every pane on a host whose admin turned `AllowStreamLocalForwarding` off,
|
||||
/// and each of those is a full round trip.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RemoteEntry {
|
||||
/// `direct-streamlocal@openssh.com` straight to this absolute remote path.
|
||||
StreamLocal { socket: String },
|
||||
/// A session channel running `command`, which bridges its own stdio to that
|
||||
/// same socket.
|
||||
SessionExec { command: String },
|
||||
}
|
||||
|
||||
impl RemoteEntry {
|
||||
/// The label this entry's links appear under in logs.
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
RemoteEntry::StreamLocal { .. } => "streamlocal",
|
||||
RemoteEntry::SessionExec { .. } => "session-exec",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the way in, from what a probe of the remote learned.
|
||||
///
|
||||
/// Split out as a pure function because the real decision is impossible to
|
||||
/// unit-test end to end — it needs an sshd with `AllowStreamLocalForwarding`
|
||||
/// flipped both ways — while the *policy* is exactly the part worth pinning:
|
||||
///
|
||||
/// | remote socket path | forwarding allowed | entry |
|
||||
/// |---|---|---|
|
||||
/// | resolved | yes | [`RemoteEntry::StreamLocal`] |
|
||||
/// | resolved | no | [`RemoteEntry::SessionExec`] |
|
||||
/// | unresolved | either | [`RemoteEntry::SessionExec`] |
|
||||
///
|
||||
/// An unresolved path forces the bridge even where forwarding is allowed:
|
||||
/// `direct-streamlocal` carries an absolute path and nothing else, so without
|
||||
/// one there is no request to make — whereas `tty7-server --stdio` resolves the
|
||||
/// path in the process that will actually bind it.
|
||||
pub fn choose_entry(
|
||||
socket: Option<&str>,
|
||||
forwarding_allowed: bool,
|
||||
server_command: &str,
|
||||
) -> RemoteEntry {
|
||||
match socket {
|
||||
Some(socket) if forwarding_allowed && !socket.is_empty() => RemoteEntry::StreamLocal {
|
||||
socket: socket.to_string(),
|
||||
},
|
||||
_ => RemoteEntry::SessionExec {
|
||||
command: server_command.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The four remote environment variables the control socket path is derived
|
||||
/// from. Read off the remote in one `exec`, never guessed from this machine's
|
||||
/// own environment — a macOS client has no `$XDG_RUNTIME_DIR` and a Linux
|
||||
/// server usually does.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RemoteEnv {
|
||||
pub control_sock: Option<String>,
|
||||
pub xdg_runtime_dir: Option<String>,
|
||||
pub home: Option<String>,
|
||||
pub tmpdir: Option<String>,
|
||||
}
|
||||
|
||||
/// Marker every probe line carries, so a remote whose startup files print a
|
||||
/// banner (or a `fish` that greets) doesn't corrupt the answer. Same tactic as
|
||||
/// [`crate::daemon::shell_integration::remote`]'s shell probe.
|
||||
const ENV_MARKER: &str = "__tty7_env__";
|
||||
|
||||
/// The probe itself, wrapped in `sh -c` because the login shell it is handed to
|
||||
/// may be `fish`, which does not speak `${VAR-}`.
|
||||
pub const REMOTE_ENV_PROBE: &str = concat!(
|
||||
"sh -c 'printf \"__tty7_env__ %s\\n\" ",
|
||||
"\"sock=${TTY7_CONTROL_SOCK-}\" \"xdg=${XDG_RUNTIME_DIR-}\" ",
|
||||
"\"home=${HOME-}\" \"tmp=${TMPDIR-}\"'"
|
||||
);
|
||||
|
||||
impl RemoteEnv {
|
||||
/// Parse [`REMOTE_ENV_PROBE`]'s output, ignoring everything unmarked.
|
||||
pub fn parse_probe(out: &str) -> RemoteEnv {
|
||||
let mut env = RemoteEnv::default();
|
||||
for line in out.lines() {
|
||||
let Some(rest) = line.trim().strip_prefix(ENV_MARKER) else {
|
||||
continue;
|
||||
};
|
||||
let Some((key, value)) = rest.trim_start().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
// An unset variable prints empty; keep it `None` so the fallbacks
|
||||
// below treat it as absent rather than as the empty path.
|
||||
let value = (!value.is_empty()).then(|| value.to_string());
|
||||
match key {
|
||||
"sock" => env.control_sock = value,
|
||||
"xdg" => env.xdg_runtime_dir = value,
|
||||
"home" => env.home = value,
|
||||
"tmp" => env.tmpdir = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the remote's `tty7-server` listens for control connections, derived
|
||||
/// from *its* environment.
|
||||
///
|
||||
/// This mirrors `host::server`'s `control_socket_path` step for step, because
|
||||
/// the two have to agree byte for byte: this side asks `direct-streamlocal` for
|
||||
/// a path, and the far side binds one, and nothing in between reconciles them.
|
||||
///
|
||||
/// | Order | Path |
|
||||
/// |---|---|
|
||||
/// | 1 | `$TTY7_CONTROL_SOCK` |
|
||||
/// | 2 | `$XDG_RUNTIME_DIR/tty7/daemon.sock` |
|
||||
/// | 3 | `$HOME/.local/share/tty7/daemon.sock` |
|
||||
/// | 4 | `<runtime-or-tmp>/tty7-<hash>.sock`, when any of the above overruns `sun_path` |
|
||||
///
|
||||
/// The hashed name is *not* automatically shorter: a deep `$XDG_RUNTIME_DIR`
|
||||
/// overruns `sun_path` on its own, which is the hole
|
||||
/// [`crate::daemon::transport`] was fixed for. Every candidate base is
|
||||
/// length-checked, and `None` — rather than a path the server will not be on —
|
||||
/// is the answer when none fits, which puts the session down the `--stdio`
|
||||
/// bridge that resolves the path in the process that binds it.
|
||||
///
|
||||
/// Paths are joined as POSIX strings, never `PathBuf`: on a Windows client
|
||||
/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7` (contract §4.3).
|
||||
pub fn remote_control_socket(env: &RemoteEnv) -> Option<String> {
|
||||
if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) {
|
||||
return Some(explicit.to_string());
|
||||
}
|
||||
|
||||
let runtime = env.xdg_runtime_dir.as_deref().filter(|d| !d.is_empty());
|
||||
let dir = match runtime {
|
||||
Some(runtime) => posix_join(runtime, "tty7"),
|
||||
None => {
|
||||
let home = env.home.as_deref().filter(|h| !h.is_empty())?;
|
||||
posix_join(&posix_join(&posix_join(home, ".local"), "share"), "tty7")
|
||||
}
|
||||
};
|
||||
|
||||
let inline = posix_join(&dir, "daemon.sock");
|
||||
if fits(&inline) {
|
||||
return Some(inline);
|
||||
}
|
||||
|
||||
let name = format!("tty7-{:016x}.sock", crate::host::fnv1a64(dir.as_bytes()));
|
||||
let tmp = env
|
||||
.tmpdir
|
||||
.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or("/tmp");
|
||||
[runtime, Some(tmp)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|base| posix_join(base, &name))
|
||||
.find(|candidate| fits(candidate))
|
||||
}
|
||||
|
||||
/// `Path::join`'s behaviour, spelled out for POSIX strings: one separator, no
|
||||
/// doubling when the base already ends in one.
|
||||
fn posix_join(base: &str, name: &str) -> String {
|
||||
format!("{}/{name}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
fn fits(path: &str) -> bool {
|
||||
path.len() <= MAX_SOCKET_PATH_BYTES
|
||||
}
|
||||
|
||||
impl AsyncRead for RemoteLink {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
RemoteLink::StreamLocal(s) | RemoteLink::SessionExec(s) => {
|
||||
Pin::new(s).poll_read(cx, buf)
|
||||
}
|
||||
RemoteLink::Wsl(s) | RemoteLink::LocalStdio(s) => Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for RemoteLink {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
RemoteLink::StreamLocal(s) | RemoteLink::SessionExec(s) => {
|
||||
Pin::new(s).poll_write(cx, buf)
|
||||
}
|
||||
RemoteLink::Wsl(s) | RemoteLink::LocalStdio(s) => Pin::new(s).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
RemoteLink::StreamLocal(s) | RemoteLink::SessionExec(s) => Pin::new(s).poll_flush(cx),
|
||||
RemoteLink::Wsl(s) | RemoteLink::LocalStdio(s) => Pin::new(s).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every variant implements shutdown for real. A half-close is how the
|
||||
/// remote learns the client is finished rather than merely quiet, and a
|
||||
/// `poll_shutdown` that returned `Ready(Ok(()))` without acting would strand
|
||||
/// the remote waiting on a stream that will never carry another byte.
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
RemoteLink::StreamLocal(s) | RemoteLink::SessionExec(s) => {
|
||||
Pin::new(s).poll_shutdown(cx)
|
||||
}
|
||||
RemoteLink::Wsl(s) | RemoteLink::LocalStdio(s) => Pin::new(s).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RemoteLink {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("RemoteLink")
|
||||
.field(&self.kind_label())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A child process's stdio really is a duplex stream: bytes written reach
|
||||
/// the child's stdin and its stdout comes back, through the same
|
||||
/// `AsyncRead`/`AsyncWrite` the SSH variants use. This is the path the
|
||||
/// end-to-end test rides, so it has to work before there is a server to
|
||||
/// point it at.
|
||||
#[tokio::test]
|
||||
async fn a_local_stdio_child_round_trips_bytes() {
|
||||
let mut link = RemoteLink::local_stdio("cat", &[]).unwrap();
|
||||
assert_eq!(link.kind_label(), "local-stdio");
|
||||
assert!(link.is_stdio_bridge());
|
||||
assert!(!link.is_ssh());
|
||||
|
||||
link.write_all(b"hello remote\n").await.unwrap();
|
||||
link.flush().await.unwrap();
|
||||
|
||||
let mut got = vec![0u8; 13];
|
||||
link.read_exact(&mut got).await.unwrap();
|
||||
assert_eq!(&got, b"hello remote\n");
|
||||
}
|
||||
|
||||
/// Shutting the write half down is what tells the peer "no more input" —
|
||||
/// `cat` answers by closing its stdout, which surfaces here as EOF. A
|
||||
/// no-op `poll_shutdown` would hang this test forever.
|
||||
#[tokio::test]
|
||||
async fn shutdown_closes_the_write_half_and_the_peer_sees_eof() {
|
||||
let mut link = RemoteLink::local_stdio("cat", &[]).unwrap();
|
||||
link.write_all(b"bye").await.unwrap();
|
||||
link.shutdown().await.unwrap();
|
||||
|
||||
let mut rest = Vec::new();
|
||||
link.read_to_end(&mut rest).await.unwrap();
|
||||
assert_eq!(rest, b"bye");
|
||||
}
|
||||
|
||||
/// Dropping the link reaps the child. Without `kill_on_drop` a failed test
|
||||
/// would leave a `tty7-server` running against a socket nobody holds.
|
||||
#[tokio::test]
|
||||
async fn dropping_the_link_kills_the_child() {
|
||||
let link = RemoteLink::local_stdio("sleep", &["300"]).unwrap();
|
||||
drop(link);
|
||||
// The child is reaped asynchronously by tokio; what matters is that the
|
||||
// handle is gone and nothing here leaks. A surviving process would show
|
||||
// up as a hung test run rather than an assertion, which is why this is
|
||||
// mostly a statement of intent — `kill_on_drop(true)` above is the
|
||||
// mechanism.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
/// The labels are the diagnostic value the four variants exist for, so they
|
||||
/// are pinned: a log line reading "streamlocal" has to keep meaning that.
|
||||
#[test]
|
||||
fn every_variant_has_a_distinct_label() {
|
||||
// Constructed without processes: only the discriminant is exercised.
|
||||
let labels = ["streamlocal", "session-exec", "wsl-stdio", "local-stdio"];
|
||||
let mut sorted = labels.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), labels.len(), "labels must be distinguishable");
|
||||
}
|
||||
|
||||
// -- the way in ---------------------------------------------------------
|
||||
|
||||
/// The whole fallback policy, which a live sshd cannot be asked to
|
||||
/// demonstrate both halves of in one test run.
|
||||
#[test]
|
||||
fn the_entry_falls_back_exactly_when_streamlocal_cannot_be_used() {
|
||||
let cmd = "tty7-server --stdio";
|
||||
assert_eq!(
|
||||
choose_entry(Some("/run/user/1000/tty7/daemon.sock"), true, cmd),
|
||||
RemoteEntry::StreamLocal {
|
||||
socket: "/run/user/1000/tty7/daemon.sock".into()
|
||||
}
|
||||
);
|
||||
// `AllowStreamLocalForwarding no`: the path is known and useless.
|
||||
assert_eq!(
|
||||
choose_entry(Some("/run/user/1000/tty7/daemon.sock"), false, cmd),
|
||||
RemoteEntry::SessionExec {
|
||||
command: cmd.into()
|
||||
}
|
||||
);
|
||||
// No path to ask for. The bridge resolves it in the process that binds
|
||||
// it, so this is a fallback with *more* information, not less.
|
||||
assert_eq!(
|
||||
choose_entry(None, true, cmd),
|
||||
RemoteEntry::SessionExec {
|
||||
command: cmd.into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
choose_entry(None, false, cmd),
|
||||
RemoteEntry::SessionExec {
|
||||
command: cmd.into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The probe's output is read by marker, so a remote that greets, warns, or
|
||||
/// prints a MOTD before the answer is still parsed correctly — and an unset
|
||||
/// variable stays absent rather than becoming the empty path.
|
||||
#[test]
|
||||
fn the_env_probe_survives_a_chatty_remote() {
|
||||
let out = "Welcome to Ubuntu!\n\
|
||||
__tty7_env__ sock=\n\
|
||||
__tty7_env__ xdg=/run/user/1000\n\
|
||||
__tty7_env__ home=/home/me\n\
|
||||
__tty7_env__ tmp=\n\
|
||||
You have mail.\n";
|
||||
let env = RemoteEnv::parse_probe(out);
|
||||
assert_eq!(env.control_sock, None);
|
||||
assert_eq!(env.xdg_runtime_dir.as_deref(), Some("/run/user/1000"));
|
||||
assert_eq!(env.home.as_deref(), Some("/home/me"));
|
||||
assert_eq!(env.tmpdir, None);
|
||||
}
|
||||
|
||||
/// The remote path, in the order `host::server::control_socket_path`
|
||||
/// resolves it. Pinned as literals: these strings are compared — over a
|
||||
/// wire, with no error message — against what a *different binary* on a
|
||||
/// *different machine* computed, so an "equivalent" refactor of either side
|
||||
/// is a silent connection failure.
|
||||
#[test]
|
||||
fn the_remote_socket_path_matches_the_servers_own_order() {
|
||||
let explicit = RemoteEnv {
|
||||
control_sock: Some("/tmp/mine.sock".into()),
|
||||
xdg_runtime_dir: Some("/run/user/1000".into()),
|
||||
home: Some("/home/me".into()),
|
||||
..RemoteEnv::default()
|
||||
};
|
||||
assert_eq!(
|
||||
remote_control_socket(&explicit).as_deref(),
|
||||
Some("/tmp/mine.sock"),
|
||||
"an explicit $TTY7_CONTROL_SOCK outranks everything"
|
||||
);
|
||||
|
||||
let xdg = RemoteEnv {
|
||||
xdg_runtime_dir: Some("/run/user/1000".into()),
|
||||
home: Some("/home/me".into()),
|
||||
..RemoteEnv::default()
|
||||
};
|
||||
assert_eq!(
|
||||
remote_control_socket(&xdg).as_deref(),
|
||||
Some("/run/user/1000/tty7/daemon.sock")
|
||||
);
|
||||
|
||||
// A trailing separator must not double up: the server derives its path
|
||||
// through `Path::join`, which collapses it.
|
||||
let trailing = RemoteEnv {
|
||||
xdg_runtime_dir: Some("/run/user/1000/".into()),
|
||||
..RemoteEnv::default()
|
||||
};
|
||||
assert_eq!(
|
||||
remote_control_socket(&trailing).as_deref(),
|
||||
Some("/run/user/1000/tty7/daemon.sock")
|
||||
);
|
||||
|
||||
let home_only = RemoteEnv {
|
||||
home: Some("/home/me".into()),
|
||||
..RemoteEnv::default()
|
||||
};
|
||||
assert_eq!(
|
||||
remote_control_socket(&home_only).as_deref(),
|
||||
Some("/home/me/.local/share/tty7/daemon.sock")
|
||||
);
|
||||
|
||||
// Nothing to derive from at all.
|
||||
assert_eq!(remote_control_socket(&RemoteEnv::default()), None);
|
||||
}
|
||||
|
||||
/// The hole `daemon::transport` was fixed for, on the remote side: the
|
||||
/// "short" hashed name is only short relative to the *config* dir, and a
|
||||
/// deep `$XDG_RUNTIME_DIR` overruns `sun_path` just as readily. Returning
|
||||
/// an overlong path here would send `direct-streamlocal` at an address the
|
||||
/// server could never have bound.
|
||||
#[test]
|
||||
fn a_deep_runtime_dir_never_yields_an_unbindable_path() {
|
||||
let deep = format!("/run/user/1000/{}", "nested/".repeat(12));
|
||||
let env = RemoteEnv {
|
||||
control_sock: None,
|
||||
xdg_runtime_dir: Some(deep.clone()),
|
||||
home: Some("/home/me".into()),
|
||||
tmpdir: Some("/tmp".into()),
|
||||
};
|
||||
let path = remote_control_socket(&env).expect("the temp dir is short enough");
|
||||
assert!(
|
||||
path.len() <= MAX_SOCKET_PATH_BYTES,
|
||||
"{path} ({} bytes) would be rejected by bind()",
|
||||
path.len()
|
||||
);
|
||||
// …and it lands in the temp dir, because the runtime dir itself is what
|
||||
// was too long.
|
||||
assert!(
|
||||
path.starts_with("/tmp/tty7-"),
|
||||
"unexpected fallback: {path}"
|
||||
);
|
||||
|
||||
// When *no* base is short enough, the honest answer is "no path" — the
|
||||
// session then takes the stdio bridge, which resolves it remotely.
|
||||
let hopeless = RemoteEnv {
|
||||
control_sock: None,
|
||||
xdg_runtime_dir: Some(deep.clone()),
|
||||
home: Some("/home/me".into()),
|
||||
tmpdir: Some(deep),
|
||||
};
|
||||
assert_eq!(remote_control_socket(&hopeless), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::daemon::pane::DaemonPane;
|
||||
use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, PROTOCOL_VERSION, RemoteKind};
|
||||
use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, RemoteKind};
|
||||
use crate::daemon::ssh::SshConnection;
|
||||
use crate::daemon::transport::{self, Stream};
|
||||
|
||||
@@ -234,7 +234,23 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
// directions are independent).
|
||||
let write_stream = read_stream.try_clone()?;
|
||||
|
||||
let first = ClientMsg::read(&mut read_stream)?;
|
||||
// The opening frame decides whether this connection is *ours* at all. A
|
||||
// route header means the rest of it belongs to a remote `tty7-server`, and
|
||||
// this daemon becomes a byte pipe for the remainder of its life — see
|
||||
// `daemon::router`. Read at the frame level rather than through
|
||||
// `ClientMsg::read` because a routed connection's later bytes are not this
|
||||
// dialect, and nothing here may assume they are.
|
||||
let (first_kind, first_payload) = crate::daemon::protocol::read_frame(&mut read_stream)?;
|
||||
if first_kind == crate::daemon::router::ROUTE_KIND {
|
||||
drop(write_stream);
|
||||
let header = crate::daemon::router::RouteHeader::decode(&first_payload)?;
|
||||
return Ok(crate::daemon::router::RemoteRouter::route(
|
||||
read_stream,
|
||||
&header,
|
||||
)?);
|
||||
}
|
||||
|
||||
let first = ClientMsg::from_frame(first_kind, first_payload)?;
|
||||
match first {
|
||||
ClientMsg::Spawn { cwd, size, shell } => {
|
||||
let id = registry.alloc_id();
|
||||
@@ -332,11 +348,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
|
||||
ClientMsg::Version => {
|
||||
let mut w = write_stream;
|
||||
DaemonMsg::Version(DaemonVersion {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
build: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})
|
||||
.encode(&mut w)?;
|
||||
DaemonMsg::Version(DaemonVersion::current()).encode(&mut w)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -526,6 +538,16 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A remote workspace has no pane here to address, so its forwards and
|
||||
// SFTP go through one envelope that names the connection instead
|
||||
// (design §15). The whole answer — including every failure — is built by
|
||||
// `ssh::workspace::handle`, so this arm stays a pipe.
|
||||
ClientMsg::OnWorkspace(req) => {
|
||||
let mut w = write_stream;
|
||||
crate::daemon::ssh::workspace::handle(&req).encode(&mut w)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// `Input` / `Resize` / `Detach` as an opening message are meaningless (no
|
||||
// pane is bound yet); ignore and close.
|
||||
other => {
|
||||
@@ -987,8 +1009,13 @@ mod tests {
|
||||
|
||||
// Bounded poll rather than a bare `join()`: the sender stays alive
|
||||
// for the whole wait, so only the write-failure path can finish the
|
||||
// thread — and a regression fails in ~5 s instead of hanging.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
// thread — and a regression fails in bounded time instead of
|
||||
// hanging. The bound is generous because it is only a hang-catcher:
|
||||
// the passing case finishes in microseconds, while a loaded machine
|
||||
// running the whole suite in parallel can leave this thread
|
||||
// unscheduled for seconds. A tight bound turns that into a flake
|
||||
// that says nothing about the behaviour under test.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while !writer.is_finished() && std::time::Instant::now() < deadline {
|
||||
thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
@@ -618,6 +618,7 @@ mod tests {
|
||||
DaemonMsg::Version(DaemonVersion {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
build: "test".into(),
|
||||
features: Vec::new(),
|
||||
})
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
@@ -10,6 +10,7 @@
|
||||
//! spec (pre-resolved from the keychain by the GUI) or, failing that, from the
|
||||
//! [`PromptBroker`]. Secrets are never logged.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,7 +18,9 @@ use russh::client::{AuthResult, Handle, KeyboardInteractiveAuthResponse};
|
||||
use russh::keys::agent::AgentIdentity;
|
||||
use russh::keys::agent::client::AgentClient;
|
||||
use russh::keys::{Algorithm, HashAlg, PrivateKeyWithHashAlg, PublicKey};
|
||||
use russh::{GssapiAuthenticator, GssapiStep, MethodKind, MethodSet};
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
use russh::{GssapiAuthenticator, GssapiStep};
|
||||
use russh::{MethodKind, MethodSet};
|
||||
|
||||
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, KiPrompt, NativeSshSpec, SshAuthMode};
|
||||
|
||||
@@ -120,14 +123,15 @@ fn failed(reason: impl Into<String>) -> Outcome {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
const KRB5_DER_OID: &[u8] = b"\x06\x09\x2a\x86\x48\x86\xf7\x12\x01\x02\x02";
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
struct GssapiClient {
|
||||
ctx: libgssapi::context::ClientCtx,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
#[derive(Debug)]
|
||||
enum GssapiAuthError {
|
||||
Send(russh::SendError),
|
||||
@@ -135,7 +139,7 @@ enum GssapiAuthError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
impl std::fmt::Display for GssapiAuthError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -146,21 +150,21 @@ impl std::fmt::Display for GssapiAuthError {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
impl From<russh::SendError> for GssapiAuthError {
|
||||
fn from(value: russh::SendError) -> Self {
|
||||
GssapiAuthError::Send(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
impl From<libgssapi::error::Error> for GssapiAuthError {
|
||||
fn from(value: libgssapi::error::Error) -> Self {
|
||||
GssapiAuthError::Gssapi(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
impl GssapiAuthenticator for GssapiClient {
|
||||
type Error = GssapiAuthError;
|
||||
|
||||
@@ -202,7 +206,7 @@ impl GssapiAuthenticator for GssapiClient {
|
||||
}
|
||||
|
||||
async fn try_gssapi(handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec) -> Outcome {
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
{
|
||||
use libgssapi::context::{ClientCtx, CtxFlags};
|
||||
use libgssapi::name::Name;
|
||||
@@ -272,14 +276,14 @@ async fn try_gssapi(handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec) ->
|
||||
))
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(all(unix, feature = "gssapi")))]
|
||||
{
|
||||
let _ = (handle, spec);
|
||||
failed("gssapi auth is only implemented on Unix")
|
||||
failed("gssapi auth is not available in this build")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
async fn gssapi_service_hosts(host: &str) -> Vec<String> {
|
||||
let host = host.to_string();
|
||||
let fallback = host.clone();
|
||||
@@ -288,12 +292,23 @@ async fn gssapi_service_hosts(host: &str) -> Vec<String> {
|
||||
.unwrap_or_else(|_| vec![fallback])
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn gssapi_service_hosts_blocking(host: &str) -> Vec<String> {
|
||||
gssapi_service_hosts_with_lookup(host, reverse_lookup_addr)
|
||||
}
|
||||
|
||||
/// Which host names to request a `host/<name>` Kerberos service ticket for: the
|
||||
/// host as typed, plus its reverse-DNS name when it was typed as a bare IP.
|
||||
///
|
||||
/// Deliberately gated on `unix` alone, **not** on `feature = "gssapi"`. It needs
|
||||
/// nothing from libgssapi — the caller injects the resolver — and gating it also
|
||||
/// gated its two unit tests, which then only ran because the GUI package enables
|
||||
/// `gssapi` and cargo unifies features across a `--workspace` test run. Narrowing
|
||||
/// to `cargo test -p tty7-core` (a bisect, a single-crate iteration) silently
|
||||
/// dropped them: green run, test never compiled. Without the feature the only
|
||||
/// caller is the test module below, hence the `allow`.
|
||||
#[cfg(unix)]
|
||||
#[cfg_attr(not(feature = "gssapi"), allow(dead_code))]
|
||||
fn gssapi_service_hosts_with_lookup(
|
||||
host: &str,
|
||||
reverse_lookup: impl FnOnce(IpAddr) -> Option<String>,
|
||||
@@ -310,7 +325,7 @@ fn gssapi_service_hosts_with_lookup(
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn reverse_lookup_addr(ip: IpAddr) -> Option<String> {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => reverse_lookup_v4(ip),
|
||||
@@ -318,7 +333,7 @@ fn reverse_lookup_addr(ip: IpAddr) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn reverse_lookup_v4(ip: std::net::Ipv4Addr) -> Option<String> {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
set_sockaddr_in_len(&mut addr);
|
||||
@@ -332,7 +347,7 @@ fn reverse_lookup_v4(ip: std::net::Ipv4Addr) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn reverse_lookup_v6(ip: std::net::Ipv6Addr) -> Option<String> {
|
||||
let mut addr: libc::sockaddr_in6 = unsafe { std::mem::zeroed() };
|
||||
set_sockaddr_in6_len(&mut addr);
|
||||
@@ -346,7 +361,7 @@ fn reverse_lookup_v6(ip: std::net::Ipv6Addr) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn reverse_lookup_sockaddr(addr: *const libc::sockaddr, len: libc::socklen_t) -> Option<String> {
|
||||
const NI_MAXHOST_FALLBACK: usize = 1025;
|
||||
let mut host = [0 as libc::c_char; NI_MAXHOST_FALLBACK];
|
||||
@@ -378,7 +393,7 @@ fn reverse_lookup_sockaddr(addr: *const libc::sockaddr, len: libc::socklen_t) ->
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
))]
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn set_sockaddr_in_len(addr: &mut libc::sockaddr_in) {
|
||||
addr.sin_len = std::mem::size_of::<libc::sockaddr_in>() as u8;
|
||||
}
|
||||
@@ -391,7 +406,7 @@ fn set_sockaddr_in_len(addr: &mut libc::sockaddr_in) {
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
)))]
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn set_sockaddr_in_len(_addr: &mut libc::sockaddr_in) {}
|
||||
|
||||
#[cfg(any(
|
||||
@@ -402,7 +417,7 @@ fn set_sockaddr_in_len(_addr: &mut libc::sockaddr_in) {}
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
))]
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn set_sockaddr_in6_len(addr: &mut libc::sockaddr_in6) {
|
||||
addr.sin6_len = std::mem::size_of::<libc::sockaddr_in6>() as u8;
|
||||
}
|
||||
@@ -415,7 +430,7 @@ fn set_sockaddr_in6_len(addr: &mut libc::sockaddr_in6) {
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
)))]
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn set_sockaddr_in6_len(_addr: &mut libc::sockaddr_in6) {}
|
||||
|
||||
/// Try identity files (unless mode is `Agent`) then the ssh-agent (unless mode is
|
||||
@@ -835,6 +850,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// `#[cfg(unix)]`, not `#[cfg(all(unix, feature = "gssapi"))]`: these exercise
|
||||
// pure host-list logic, so they must run under a plain
|
||||
// `cargo test -p tty7-core` too. See `gssapi_service_hosts_with_lookup`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gssapi_service_hosts_keep_original_host_before_reverse_dns() {
|
||||
@@ -82,10 +82,48 @@ impl AsyncWrite for Transport {
|
||||
pub struct ProcessStream {
|
||||
// Held so the child is reaped on drop; not otherwise read.
|
||||
_child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
/// `Option` so `poll_shutdown` can *drop* it.
|
||||
///
|
||||
/// This is the only way to half-close a pipe. `ChildStdin`'s own
|
||||
/// `poll_shutdown` returns `Ready(Ok(()))` without touching the file
|
||||
/// descriptor, so the child never sees EOF and keeps waiting for input that
|
||||
/// will never come — a `tty7-server --stdio` bridge would hang there
|
||||
/// forever instead of exiting. Closing the write half is a real operation
|
||||
/// and has to be modelled as one.
|
||||
stdin: Option<tokio::process::ChildStdin>,
|
||||
stdout: tokio::process::ChildStdout,
|
||||
}
|
||||
|
||||
impl ProcessStream {
|
||||
/// Assemble one from an already-spawned child and its taken pipes.
|
||||
///
|
||||
/// The fields stay private — a `ProcessStream` whose `_child` did not
|
||||
/// produce its own `stdin`/`stdout` would reap the wrong process on drop.
|
||||
/// `daemon::remote_link` needs this to wrap a `tty7-server --stdio` child
|
||||
/// the same way the `ProxyCommand` path wraps its own.
|
||||
pub fn from_parts(
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: tokio::process::ChildStdout,
|
||||
) -> ProcessStream {
|
||||
ProcessStream {
|
||||
_child: child,
|
||||
stdin: Some(stdin),
|
||||
stdout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The write half, or a "already closed" error once it has been shut down.
|
||||
fn stdin_mut(&mut self) -> std::io::Result<&mut tokio::process::ChildStdin> {
|
||||
self.stdin.as_mut().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"the process stream's write half is already closed",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for ProcessStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
@@ -102,13 +140,40 @@ impl AsyncWrite for ProcessStream {
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
Pin::new(&mut self.get_mut().stdin).poll_write(cx, buf)
|
||||
match self.get_mut().stdin_mut() {
|
||||
Ok(stdin) => Pin::new(stdin).poll_write(cx, buf),
|
||||
Err(e) => Poll::Ready(Err(e)),
|
||||
}
|
||||
}
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().stdin).poll_flush(cx)
|
||||
match self.get_mut().stdin_mut() {
|
||||
Ok(stdin) => Pin::new(stdin).poll_flush(cx),
|
||||
// Nothing buffered can remain once the half is closed.
|
||||
Err(_) => Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush, then **close** the write half by dropping the pipe.
|
||||
///
|
||||
/// Delegating to `ChildStdin::poll_shutdown` would be a no-op — it does not
|
||||
/// close the descriptor — so the peer would never reach EOF. Dropping is
|
||||
/// what actually closes it, which is why `stdin` is an `Option`.
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().stdin).poll_shutdown(cx)
|
||||
let this = self.get_mut();
|
||||
let Some(stdin) = this.stdin.as_mut() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
};
|
||||
match Pin::new(stdin).poll_flush(cx) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
this.stdin = None;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Err(e)) => {
|
||||
this.stdin = None;
|
||||
Poll::Ready(Err(e))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,11 +253,9 @@ fn spawn_proxy_command(
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("ProxyCommand stdout unavailable"))?;
|
||||
Ok(Transport::Process(ProcessStream {
|
||||
_child: child,
|
||||
stdin,
|
||||
stdout,
|
||||
}))
|
||||
Ok(Transport::Process(ProcessStream::from_parts(
|
||||
child, stdin, stdout,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Split a ProxyCommand template into argv and substitute the OpenSSH tokens
|
||||
@@ -15,13 +15,32 @@
|
||||
//! connection to the registered target. Unmatched channels are rejected.
|
||||
//!
|
||||
//! **Registry keying & blast radius.** [`SshForwardRegistry`] keys active forwards
|
||||
//! by `pane_id` (so the UI lists them per pane) but each forward task holds an
|
||||
//! `Arc<SshConnection>`, so a forward keeps the shared connection alive exactly
|
||||
//! like `ssh -N`. When a pane dies the daemon calls
|
||||
//! [`SshForwardRegistry::teardown_pane`], which aborts its listener tasks and
|
||||
//! cancels its remote bindings; dropping the last `Arc` then tears the connection
|
||||
//! down. When the *transport* drops, every pane sharing the connection dies as a
|
||||
//! unit (FR-C2), so every forward attributed to those panes is torn down together.
|
||||
//! by [`ForwardOwner`] — *what has to die for this forward to die* — but each
|
||||
//! forward task holds an `Arc<SshConnection>`, so a forward keeps the shared
|
||||
//! connection alive exactly like `ssh -N`.
|
||||
//!
|
||||
//! There are two owners, because tty7 has two unrelated features that both open
|
||||
//! forwards (design §2):
|
||||
//!
|
||||
//! | | SSH pane ("连一下") | remote workspace ("在上面开发") |
|
||||
//! |---|---|---|
|
||||
//! | owner | [`ForwardOwner::Pane`] | [`ForwardOwner::Workspace`] |
|
||||
//! | unit | one pane | one window's workspace |
|
||||
//! | pane dies | forward dies with it | **forward survives** |
|
||||
//! | torn down by | [`SshForwardRegistry::teardown_pane`] | [`SshForwardRegistry::teardown_workspace`] |
|
||||
//!
|
||||
//! The two are exclusive by construction rather than by convention: an owner is
|
||||
//! one variant or the other, and `teardown_pane(id)` can only ever reach
|
||||
//! `Pane(id)`. A remote workspace's panes come and go — a tab closed, a pane
|
||||
//! respawned after a reconnect — and the `localhost:3000` forward the user
|
||||
//! ⌘-clicked has to outlive all of that, while an SSH pane's forwards must still
|
||||
//! vanish the moment the pane does.
|
||||
//!
|
||||
//! When a pane dies the daemon calls [`SshForwardRegistry::teardown_pane`],
|
||||
//! which aborts its listener tasks and cancels its remote bindings; dropping the
|
||||
//! last `Arc` then tears the connection down. When the *transport* drops, every
|
||||
//! pane sharing the connection dies as a unit (FR-C2), so every forward
|
||||
//! attributed to those panes is torn down together.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
@@ -33,11 +52,13 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::core::session::WorkspaceId;
|
||||
use crate::daemon::protocol::{
|
||||
ForwardStatus, LoopbackForward, ManagedForward, SshForwardKind, SshForwardRule,
|
||||
ForwardStatus, LoopbackForward, ManagedForward, NativeSshSpec, SshForwardKind, SshForwardRule,
|
||||
};
|
||||
|
||||
use super::session::SshConnection;
|
||||
use super::{ConnectionKey, SshManager};
|
||||
|
||||
/// Accept a connection, retrying transient errors instead of killing the
|
||||
/// listener: ECONNABORTED (client gave up mid-handshake) and EMFILE/ENFILE
|
||||
@@ -308,6 +329,20 @@ struct ForwardEntry {
|
||||
auto_local: bool,
|
||||
}
|
||||
|
||||
/// What a managed forward belongs to — the thing whose death takes it down.
|
||||
///
|
||||
/// The registry is keyed on this rather than on a bare `pane_id` so that the two
|
||||
/// features that open forwards can coexist without either one's teardown being
|
||||
/// able to reach the other's entries (see the module docs).
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum ForwardOwner {
|
||||
/// A native-SSH pane. Its forwards die with it, via [`SshForwardRegistry::teardown_pane`].
|
||||
Pane(u64),
|
||||
/// A remote workspace. Its forwards outlive every individual pane and die
|
||||
/// only with the workspace, via [`SshForwardRegistry::teardown_workspace`].
|
||||
Workspace(WorkspaceId),
|
||||
}
|
||||
|
||||
impl ForwardEntry {
|
||||
fn to_managed(&self, pane_id: u64) -> ManagedForward {
|
||||
ManagedForward {
|
||||
@@ -327,11 +362,17 @@ impl ForwardEntry {
|
||||
/// The per-process registry of managed forwards, owned by [`super::SshManager`].
|
||||
#[derive(Default)]
|
||||
pub struct SshForwardRegistry {
|
||||
panes: Mutex<HashMap<u64, Vec<ForwardEntry>>>,
|
||||
owners: Mutex<HashMap<ForwardOwner, Vec<ForwardEntry>>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl SshForwardRegistry {
|
||||
// ---- Pane-owned forwards (native-SSH panes) -----------------------------
|
||||
//
|
||||
// These signatures are exactly what they were before workspaces existed, and
|
||||
// every one of them pins its owner to `ForwardOwner::Pane`. A workspace
|
||||
// forward is unreachable from here, which is the compatibility guarantee.
|
||||
|
||||
/// Establish a managed forward for `rule` on `conn`, attribute it to `pane_id`,
|
||||
/// and return the resulting [`ManagedForward`] (with a resolved bind port and a
|
||||
/// live status). Failures are reported as `ForwardStatus::Error`, never a hard
|
||||
@@ -341,6 +382,82 @@ impl SshForwardRegistry {
|
||||
pane_id: u64,
|
||||
conn: Arc<SshConnection>,
|
||||
rule: &SshForwardRule,
|
||||
) -> ManagedForward {
|
||||
self.establish_owned(&ForwardOwner::Pane(pane_id), pane_id, conn, rule)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The managed forwards attributed to `pane_id`, sorted by id (creation order).
|
||||
pub fn list(&self, pane_id: u64) -> Vec<ManagedForward> {
|
||||
self.list_owned(&ForwardOwner::Pane(pane_id), pane_id)
|
||||
}
|
||||
|
||||
/// Remove one managed forward by id from `pane_id`, tearing down its listener
|
||||
/// or remote binding. Returns the pane's remaining forwards.
|
||||
pub async fn remove(&self, pane_id: u64, forward_id: u64) -> Vec<ManagedForward> {
|
||||
self.remove_owned(&ForwardOwner::Pane(pane_id), pane_id, forward_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tear down every forward attributed to `pane_id` (called when the pane dies —
|
||||
/// on explicit kill, reclaim, or connection loss). Local/Dynamic listeners are
|
||||
/// aborted synchronously; remote bindings are cancelled best-effort.
|
||||
///
|
||||
/// A *remote workspace's* forwards are untouched by this even when the dying
|
||||
/// pane belonged to that workspace: they are filed under
|
||||
/// [`ForwardOwner::Workspace`], which this key can never name.
|
||||
pub async fn teardown_pane(&self, pane_id: u64) {
|
||||
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
|
||||
}
|
||||
|
||||
// ---- Workspace-owned forwards (remote workspaces, design §15) -----------
|
||||
|
||||
/// [`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
|
||||
/// therefore lifetime — is the workspace's.
|
||||
pub async fn establish_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
conn: Arc<SshConnection>,
|
||||
rule: &SshForwardRule,
|
||||
) -> ManagedForward {
|
||||
self.establish_owned(&ForwardOwner::Workspace(workspace), view_pane, conn, rule)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The forwards a workspace owns, stamped with `view_pane` for display.
|
||||
pub fn list_workspace(&self, workspace: WorkspaceId, view_pane: u64) -> Vec<ManagedForward> {
|
||||
self.list_owned(&ForwardOwner::Workspace(workspace), view_pane)
|
||||
}
|
||||
|
||||
/// Remove one of a workspace's forwards by id; returns the rest.
|
||||
pub async fn remove_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
forward_id: u64,
|
||||
) -> Vec<ManagedForward> {
|
||||
self.remove_owned(&ForwardOwner::Workspace(workspace), view_pane, forward_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tear down every forward a workspace owns — the workspace was closed. The
|
||||
/// counterpart of [`teardown_pane`](Self::teardown_pane), and the *only* thing
|
||||
/// that collects a workspace forward.
|
||||
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
|
||||
self.teardown_owned(&ForwardOwner::Workspace(workspace))
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---- Owner-generic core -------------------------------------------------
|
||||
|
||||
async fn establish_owned(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
view_pane: u64,
|
||||
conn: Arc<SshConnection>,
|
||||
rule: &SshForwardRule,
|
||||
) -> ManagedForward {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (bind_port, status, cancel) = match rule.kind {
|
||||
@@ -360,55 +477,49 @@ impl SshForwardRegistry {
|
||||
cancel,
|
||||
auto_local: false,
|
||||
};
|
||||
let managed = entry.to_managed(pane_id);
|
||||
self.panes
|
||||
let managed = entry.to_managed(view_pane);
|
||||
self.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(pane_id)
|
||||
.entry(owner.clone())
|
||||
.or_default()
|
||||
.push(entry);
|
||||
managed
|
||||
}
|
||||
|
||||
/// The managed forwards attributed to `pane_id`, sorted by id (creation order).
|
||||
pub fn list(&self, pane_id: u64) -> Vec<ManagedForward> {
|
||||
let panes = self.panes.lock().unwrap();
|
||||
let mut list: Vec<_> = panes
|
||||
.get(&pane_id)
|
||||
fn list_owned(&self, owner: &ForwardOwner, view_pane: u64) -> Vec<ManagedForward> {
|
||||
let owners = self.owners.lock().unwrap();
|
||||
let mut list: Vec<_> = owners
|
||||
.get(owner)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| e.to_managed(pane_id))
|
||||
.map(|e| e.to_managed(view_pane))
|
||||
.collect();
|
||||
list.sort_by_key(|m| m.id);
|
||||
list
|
||||
}
|
||||
|
||||
/// Remove one managed forward by id from `pane_id`, tearing down its listener
|
||||
/// or remote binding. Returns the pane's remaining forwards.
|
||||
pub async fn remove(&self, pane_id: u64, forward_id: u64) -> Vec<ManagedForward> {
|
||||
async fn remove_owned(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
view_pane: u64,
|
||||
forward_id: u64,
|
||||
) -> Vec<ManagedForward> {
|
||||
let removed = {
|
||||
let mut panes = self.panes.lock().unwrap();
|
||||
if let Some(entries) = panes.get_mut(&pane_id) {
|
||||
if let Some(pos) = entries.iter().position(|e| e.id == forward_id) {
|
||||
Some(entries.remove(pos))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let mut owners = self.owners.lock().unwrap();
|
||||
owners.get_mut(owner).and_then(|entries| {
|
||||
let pos = entries.iter().position(|e| e.id == forward_id)?;
|
||||
Some(entries.remove(pos))
|
||||
})
|
||||
};
|
||||
if let Some(entry) = removed {
|
||||
Self::cancel_entry(entry).await;
|
||||
}
|
||||
self.list(pane_id)
|
||||
self.list_owned(owner, view_pane)
|
||||
}
|
||||
|
||||
/// Tear down every forward attributed to `pane_id` (called when the pane dies —
|
||||
/// on explicit kill, reclaim, or connection loss). Local/Dynamic listeners are
|
||||
/// aborted synchronously; remote bindings are cancelled best-effort.
|
||||
pub async fn teardown_pane(&self, pane_id: u64) {
|
||||
let entries = self.panes.lock().unwrap().remove(&pane_id);
|
||||
async fn teardown_owned(&self, owner: &ForwardOwner) {
|
||||
let entries = self.owners.lock().unwrap().remove(owner);
|
||||
for entry in entries.into_iter().flatten() {
|
||||
Self::cancel_entry(entry).await;
|
||||
}
|
||||
@@ -601,10 +712,44 @@ impl SshForwardRegistry {
|
||||
_target: &str,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> io::Result<LoopbackForward> {
|
||||
self.ensure_loopback_owned(&ForwardOwner::Pane(pane_id), conn, remote_host, remote_port)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`ensure_loopback`](Self::ensure_loopback) for a remote workspace: the
|
||||
/// ⌘-clicked `localhost:PORT` in a remote-workspace pane (design §15).
|
||||
///
|
||||
/// The forward is owned by the workspace, so clicking the link in one pane
|
||||
/// and then closing that pane leaves the browser tab working.
|
||||
pub async fn ensure_loopback_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
conn: Arc<SshConnection>,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> io::Result<LoopbackForward> {
|
||||
self.ensure_loopback_owned(
|
||||
&ForwardOwner::Workspace(workspace),
|
||||
conn,
|
||||
remote_host,
|
||||
remote_port,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_loopback_owned(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
conn: Arc<SshConnection>,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> io::Result<LoopbackForward> {
|
||||
// Dedup: a live auto-forward to the same target is reused rather than
|
||||
// duplicated (preserving the old `ensure_loopback` behavior).
|
||||
if let Some(local_port) = self.find_auto_local(pane_id, remote_host, remote_port) {
|
||||
// duplicated (preserving the old `ensure_loopback` behavior). Scoped to
|
||||
// the owner, so two workspaces on one machine don't share — and can't
|
||||
// break — each other's forward.
|
||||
if let Some(local_port) = self.find_auto_local(owner, remote_host, remote_port) {
|
||||
return Ok(LoopbackForward { local_port });
|
||||
}
|
||||
let rule = SshForwardRule {
|
||||
@@ -634,10 +779,10 @@ impl SshForwardRegistry {
|
||||
cancel,
|
||||
auto_local: true,
|
||||
};
|
||||
self.panes
|
||||
self.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(pane_id)
|
||||
.entry(owner.clone())
|
||||
.or_default()
|
||||
.push(entry);
|
||||
Ok(LoopbackForward {
|
||||
@@ -645,12 +790,17 @@ impl SshForwardRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// The local port of a live auto-created loopback forward on `pane_id` targeting
|
||||
/// `remote_host:remote_port`, if one exists (dedup for Cmd-click).
|
||||
fn find_auto_local(&self, pane_id: u64, remote_host: &str, remote_port: u16) -> Option<u16> {
|
||||
let panes = self.panes.lock().unwrap();
|
||||
panes
|
||||
.get(&pane_id)?
|
||||
/// The local port of a live auto-created loopback forward owned by `owner`
|
||||
/// targeting `remote_host:remote_port`, if one exists (dedup for Cmd-click).
|
||||
fn find_auto_local(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> Option<u16> {
|
||||
let owners = self.owners.lock().unwrap();
|
||||
owners
|
||||
.get(owner)?
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.auto_local
|
||||
@@ -663,6 +813,101 @@ impl SshForwardRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace-scoped entry points on the manager.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The blocking, workspace-scoped half of [`SshManager`]'s forward API.
|
||||
///
|
||||
/// Written here rather than in `ssh/mod.rs` deliberately: these are the sync
|
||||
/// wrappers for *this* file's registry, and keeping them beside it means the
|
||||
/// pane-scoped wrappers next door stay untouched — a workspace forward cannot
|
||||
/// be reached by editing one of them by mistake. Private fields of `SshManager`
|
||||
/// are in scope because this module is a descendant of the one that defines it.
|
||||
impl SshManager {
|
||||
/// The already-authenticated connection for `spec`'s host, if this daemon
|
||||
/// has one — **never** connecting.
|
||||
///
|
||||
/// A workspace-scoped request rides the connection the workspace itself
|
||||
/// opened, so the right answer to "no connection" is an error the user can
|
||||
/// act on ("the workspace is not connected"), not a silent second connect
|
||||
/// that would prompt for credentials from a context with nowhere to put a
|
||||
/// dialog. That is also why `spec` may be — and from the GUI always is —
|
||||
/// secret-free: [`ConnectionKey::from_spec`] reads only host, user, port,
|
||||
/// proxy and jump chain, so a stripped spec hashes to the same slot.
|
||||
pub fn existing_connection(&self, spec: &NativeSshSpec) -> Option<Arc<SshConnection>> {
|
||||
let key = ConnectionKey::from_spec(spec);
|
||||
let slot = self.conns.lock().unwrap().get(&key).cloned()?;
|
||||
// `blocking_lock` would panic on a runtime worker; `try_lock` failing
|
||||
// just means a connect for this key is in flight, which is "not ready".
|
||||
let guard = slot.try_lock().ok()?;
|
||||
let conn = guard.upgrade()?;
|
||||
conn.is_alive().then_some(conn)
|
||||
}
|
||||
|
||||
/// Establish a workspace-owned managed forward; returns the workspace's list.
|
||||
pub fn add_workspace_forward(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
conn: Arc<SshConnection>,
|
||||
rule: &SshForwardRule,
|
||||
) -> Vec<ManagedForward> {
|
||||
self.runtime.block_on(async {
|
||||
self.forwards
|
||||
.establish_workspace(workspace, view_pane, conn, rule)
|
||||
.await;
|
||||
self.forwards.list_workspace(workspace, view_pane)
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove one workspace-owned forward; returns the rest.
|
||||
pub fn remove_workspace_forward(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
forward_id: u64,
|
||||
) -> Vec<ManagedForward> {
|
||||
self.runtime.block_on(
|
||||
self.forwards
|
||||
.remove_workspace(workspace, view_pane, forward_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// A workspace's managed forwards.
|
||||
pub fn list_workspace_forwards(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
) -> Vec<ManagedForward> {
|
||||
self.forwards.list_workspace(workspace, view_pane)
|
||||
}
|
||||
|
||||
/// Drop every forward a workspace owns (the workspace was closed).
|
||||
pub fn teardown_workspace_forwards(&self, workspace: WorkspaceId) {
|
||||
self.runtime
|
||||
.block_on(self.forwards.teardown_workspace(workspace));
|
||||
}
|
||||
|
||||
/// Ensure the on-demand loopback forward behind a ⌘-clicked `localhost:PORT`
|
||||
/// in a remote-workspace pane (design §15).
|
||||
pub fn ensure_workspace_loopback(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
conn: Arc<SshConnection>,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> io::Result<LoopbackForward> {
|
||||
self.runtime
|
||||
.block_on(self.forwards.ensure_loopback_workspace(
|
||||
workspace,
|
||||
conn,
|
||||
remote_host,
|
||||
remote_port,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -831,8 +1076,8 @@ mod tests {
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut panes = reg.panes.lock().unwrap();
|
||||
let entries = panes.entry(7).or_default();
|
||||
let mut owners = reg.owners.lock().unwrap();
|
||||
let entries = owners.entry(ForwardOwner::Pane(7)).or_default();
|
||||
entries.push(make(0, 8000));
|
||||
entries.push(make(1, 8001));
|
||||
}
|
||||
@@ -900,7 +1145,12 @@ mod tests {
|
||||
// remove() path: the task's future (holding the listener) must be gone.
|
||||
let guard = Arc::new(());
|
||||
let entry = spawn_listener_entry(0, &guard).await;
|
||||
reg.panes.lock().unwrap().entry(1).or_default().push(entry);
|
||||
reg.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(ForwardOwner::Pane(1))
|
||||
.or_default()
|
||||
.push(entry);
|
||||
assert_eq!(
|
||||
Arc::strong_count(&guard),
|
||||
2,
|
||||
@@ -916,7 +1166,12 @@ mod tests {
|
||||
// teardown_pane() path (pane death / connection loss) frees it too.
|
||||
let guard2 = Arc::new(());
|
||||
let entry2 = spawn_listener_entry(1, &guard2).await;
|
||||
reg.panes.lock().unwrap().entry(2).or_default().push(entry2);
|
||||
reg.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(ForwardOwner::Pane(2))
|
||||
.or_default()
|
||||
.push(entry2);
|
||||
reg.teardown_pane(2).await;
|
||||
assert_eq!(
|
||||
Arc::strong_count(&guard2),
|
||||
@@ -925,6 +1180,129 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A live listener entry filed under `owner`, mirroring what `start_local`
|
||||
/// registers. Returns a guard whose strong count drops to 1 once the accept
|
||||
/// task (and its `TcpListener`) is fully torn down — the same race-free trick
|
||||
/// `remove_frees_listening_socket_synchronously` uses.
|
||||
async fn push_listener(reg: &SshForwardRegistry, owner: ForwardOwner, id: u64) -> Arc<()> {
|
||||
let guard = Arc::new(());
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let held = guard.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _held = held;
|
||||
while listener.accept().await.is_ok() {}
|
||||
});
|
||||
let entry = ForwardEntry {
|
||||
id,
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: port,
|
||||
target_host: "127.0.0.1".into(),
|
||||
target_port: 3000,
|
||||
description: None,
|
||||
status: ForwardStatus::Listening,
|
||||
cancel: ForwardCancel::Task(handle),
|
||||
auto_local: true,
|
||||
};
|
||||
reg.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(owner)
|
||||
.or_default()
|
||||
.push(entry);
|
||||
guard
|
||||
}
|
||||
|
||||
/// **SSH pane ownership (existing behaviour, must not regress).** A forward
|
||||
/// opened by a native-SSH pane dies with the pane: `teardown_pane` empties the
|
||||
/// list *and* frees the listening socket.
|
||||
#[tokio::test]
|
||||
async fn ssh_pane_forwards_die_with_the_pane() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
let guard = push_listener(®, ForwardOwner::Pane(7), 0).await;
|
||||
assert_eq!(reg.list(7).len(), 1, "the pane owns its forward");
|
||||
|
||||
reg.teardown_pane(7).await;
|
||||
|
||||
assert!(reg.list(7).is_empty(), "the pane's forwards are gone");
|
||||
assert_eq!(
|
||||
Arc::strong_count(&guard),
|
||||
1,
|
||||
"and its listening socket was actually released"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Remote-workspace ownership (design §15).** The panes of a remote
|
||||
/// workspace are transient — a tab closed, a pane respawned after a reconnect
|
||||
/// — so a forward the user ⌘-clicked into existence must outlive them. Only
|
||||
/// closing the *workspace* collects it.
|
||||
///
|
||||
/// The two teardowns are exercised against one registry on purpose: this is
|
||||
/// the exact case where a single `pane_id`-keyed map would have taken the
|
||||
/// workspace's forward down with the pane.
|
||||
#[tokio::test]
|
||||
async fn remote_workspace_forwards_survive_their_panes() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
let ws = WorkspaceId::new();
|
||||
// A pane of the workspace, id 7, and a same-numbered SSH-pane forward:
|
||||
// the ids collide deliberately, since a bare u64 key could not tell them
|
||||
// apart.
|
||||
let pane_guard = push_listener(®, ForwardOwner::Pane(7), 0).await;
|
||||
let ws_guard = push_listener(®, ForwardOwner::Workspace(ws), 1).await;
|
||||
|
||||
// Pane 7 dies.
|
||||
reg.teardown_pane(7).await;
|
||||
|
||||
assert!(reg.list(7).is_empty(), "the SSH pane's forward went away");
|
||||
assert_eq!(Arc::strong_count(&pane_guard), 1, "…and freed its socket");
|
||||
assert_eq!(
|
||||
reg.list_workspace(ws, 7).len(),
|
||||
1,
|
||||
"the workspace's forward is still there — the browser tab still works"
|
||||
);
|
||||
assert_eq!(
|
||||
Arc::strong_count(&ws_guard),
|
||||
2,
|
||||
"…and its listener is still bound"
|
||||
);
|
||||
|
||||
// Only closing the workspace collects it.
|
||||
reg.teardown_workspace(ws).await;
|
||||
assert!(reg.list_workspace(ws, 7).is_empty());
|
||||
assert_eq!(Arc::strong_count(&ws_guard), 1);
|
||||
}
|
||||
|
||||
/// Two workspaces on the *same machine* share one `SshConnection` but own
|
||||
/// their forwards separately: closing one leaves the other's alone, and the
|
||||
/// ⌘-click dedup does not hand one workspace the other's local port.
|
||||
#[tokio::test]
|
||||
async fn workspaces_on_one_host_do_not_share_forwards() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
let (a, b) = (WorkspaceId::new(), WorkspaceId::new());
|
||||
push_listener(®, ForwardOwner::Workspace(a), 0).await;
|
||||
push_listener(®, ForwardOwner::Workspace(b), 1).await;
|
||||
|
||||
// Dedup is owner-scoped: A's forward to 127.0.0.1:3000 is invisible to B.
|
||||
assert!(
|
||||
reg.find_auto_local(&ForwardOwner::Workspace(a), "127.0.0.1", 3000)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
reg.find_auto_local(&ForwardOwner::Pane(0), "127.0.0.1", 3000)
|
||||
.is_none(),
|
||||
"a pane never inherits a workspace's auto forward"
|
||||
);
|
||||
|
||||
reg.teardown_workspace(a).await;
|
||||
assert!(reg.list_workspace(a, 0).is_empty());
|
||||
assert_eq!(
|
||||
reg.list_workspace(b, 0).len(),
|
||||
1,
|
||||
"the other window on the same host is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// `rekey` moves a binding to the server-assigned port (bind_port 0 case).
|
||||
#[test]
|
||||
fn remote_forward_table_rekey() {
|
||||
@@ -20,11 +20,19 @@ pub mod forward;
|
||||
pub mod known_hosts;
|
||||
pub mod session;
|
||||
pub mod sftp;
|
||||
/// Workspace-scoped control requests (design §15) — see `workspace::handle`.
|
||||
pub mod workspace;
|
||||
|
||||
mod auth;
|
||||
mod connect;
|
||||
mod handler;
|
||||
|
||||
/// A child process's stdio as one duplex stream. Re-exported (rather than
|
||||
/// opening `connect` as a whole) because `daemon::remote_link` wraps a
|
||||
/// `tty7-server --stdio` child in exactly the shape the `ProxyCommand` path
|
||||
/// already uses.
|
||||
pub use connect::ProcessStream;
|
||||
|
||||
pub use broker::PromptBroker;
|
||||
pub use forward::SshForwardRegistry;
|
||||
pub use session::{ChannelCmd, SharedConnection, SshConnection, SshSessionHandle};
|
||||
@@ -41,6 +49,8 @@ use crate::daemon::protocol::{
|
||||
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, NativeSshSpec,
|
||||
SshForwardRule, SshPhase, WinSize,
|
||||
};
|
||||
use crate::daemon::remote_link::{self, RemoteEntry, RemoteLink};
|
||||
use crate::daemon::router::{RouteChannel, RouteSetup};
|
||||
use crate::daemon::shell_integration::remote;
|
||||
|
||||
use forward::RemoteForwardTable;
|
||||
@@ -57,6 +67,19 @@ const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub struct ConnectionKey(String);
|
||||
|
||||
impl ConnectionKey {
|
||||
/// The key as a string, for callers that need to *name* a connection —
|
||||
/// a log line, an error message, an installer's "which host am I writing
|
||||
/// to". Exposed because the alternative callers reach for is peeling the
|
||||
/// derived `Debug` output apart, which silently breaks the day anything
|
||||
/// about the formatting changes.
|
||||
///
|
||||
/// It is a connection identity, not a display name: it carries the proxy
|
||||
/// and jump chain, and no user-facing label. Where the user has their own
|
||||
/// name for a host, prefer that and keep this for disambiguation.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn from_spec(spec: &NativeSshSpec) -> Self {
|
||||
use crate::daemon::protocol::SshProxy;
|
||||
let mut s = format!("{}@{}:{}", spec.user, spec.host, spec.port);
|
||||
@@ -360,6 +383,165 @@ impl SshManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Remote workspaces: one logical stream to a remote `tty7-server` ----
|
||||
|
||||
/// Open one logical stream from this daemon to the `tty7-server` on `spec`'s
|
||||
/// host, reusing (or establishing) the machine's single authenticated
|
||||
/// connection.
|
||||
///
|
||||
/// **One authentication per machine.** The connection comes from the same
|
||||
/// [`ConnectionKey`] registry the SSH panes use, so a workspace opened
|
||||
/// against a host the user already has a pane on costs no prompt at all, and
|
||||
/// a second workspace on the same host costs no second prompt — each stream
|
||||
/// is a new *channel*, never a new authentication (design §7.1). One channel
|
||||
/// per pane, one per workspace control stream; no multiplexing of our own on
|
||||
/// top of SSH's.
|
||||
///
|
||||
/// The returned `Arc<SshConnection>` must be held for as long as the link is
|
||||
/// used: it is the last strong reference that keeps the shared connection
|
||||
/// (and therefore the channel) alive.
|
||||
/// **What `setup` buys.** Everything below this line may need a user: the
|
||||
/// authentication, the consent to write a binary onto the machine, the
|
||||
/// discovery that the daemon already there is a different build. `setup`
|
||||
/// carries the one client that can answer — see [`RouteSetup`].
|
||||
pub async fn open_remote_link(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
setup: &RouteSetup,
|
||||
server_command: Option<&str>,
|
||||
) -> anyhow::Result<(RemoteLink, Arc<SshConnection>)> {
|
||||
let (conn, _reused) = self.open_connection(spec, &setup.broker).await?;
|
||||
|
||||
// Before the first stream to a host, make sure the remote is actually
|
||||
// serving: the right version of `tty7-server`, installed and running.
|
||||
// Idempotent and cheap on the common path (two commands and one SFTP
|
||||
// stat, no download, no prompt), which is what makes it safe to call
|
||||
// before *every* link rather than once per connection.
|
||||
//
|
||||
// A `?` here means no link is opened at all, so "this machine has no
|
||||
// tty7-server" arrives as a route ack with a reason. B1 deliberately
|
||||
// left this un-stubbed rather than always-Ok for exactly that: an empty
|
||||
// implementation would turn a missing server into an opaque channel
|
||||
// failure much later.
|
||||
//
|
||||
// On a blocking thread because `Installer` is blocking start to finish
|
||||
// and one step of it waits on a human; running it on a runtime worker
|
||||
// would park the reactor that has to carry the answer back.
|
||||
let installed = {
|
||||
let install_conn = conn.clone();
|
||||
setup
|
||||
.blocking(move || crate::daemon::install::ensure_remote_server(&install_conn))
|
||||
.await??
|
||||
};
|
||||
|
||||
// The installed binary's **absolute** path, not the bare name. Nothing
|
||||
// puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the
|
||||
// file there is `tty7-server-<version>` — so `exec tty7-server --stdio`
|
||||
// is a `command not found` on a machine the install just succeeded on.
|
||||
// The install pass we just ran is what knows the path, so it hands it
|
||||
// over rather than leaving the transport to guess.
|
||||
let base = match server_command {
|
||||
Some(explicit) => explicit.to_string(),
|
||||
None => format!(
|
||||
"{} --stdio",
|
||||
crate::daemon::install::shell_quote(&installed)
|
||||
),
|
||||
};
|
||||
let command = setup.channel.bridge_command(&base);
|
||||
|
||||
// A pane connection never takes the cached `direct-streamlocal` entry:
|
||||
// that entry names the *control* socket, and the pane dialect is served
|
||||
// on a different one. See `RouteChannel::bridge_command`.
|
||||
let entry = match setup.channel {
|
||||
RouteChannel::Pane => RemoteEntry::SessionExec {
|
||||
command: command.clone(),
|
||||
},
|
||||
RouteChannel::Control => {
|
||||
conn.remote_entry_or_init(|| async {
|
||||
let env = probe_remote_env(&conn).await;
|
||||
let socket = env.as_ref().and_then(remote_link::remote_control_socket);
|
||||
// Optimistic: `AllowStreamLocalForwarding` defaults to `yes`
|
||||
// and the only way to learn otherwise is to be refused,
|
||||
// which the demotion below turns into a permanent,
|
||||
// connection-wide answer.
|
||||
remote_link::choose_entry(socket.as_deref(), true, &command)
|
||||
})
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
if let RemoteEntry::StreamLocal { socket } = &entry {
|
||||
match conn.open_direct_streamlocal(socket).await {
|
||||
Ok(channel) => return Ok((RemoteLink::stream_local(channel), conn)),
|
||||
Err(e) => {
|
||||
// The refusal every later stream on this connection must not
|
||||
// repeat: cache the fallback before taking it.
|
||||
log::info!(
|
||||
"ssh {:?}: direct-streamlocal to {socket} refused ({e}); \
|
||||
falling back to `{command}`",
|
||||
conn.key()
|
||||
);
|
||||
conn.set_remote_entry(remote_link::choose_entry(Some(socket), false, &command))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let channel = conn
|
||||
.open_session_channel()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("open remote workspace channel failed: {e}"))?;
|
||||
channel
|
||||
.exec(false, command.as_bytes())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("exec `{command}` on the remote failed: {e}"))?;
|
||||
Ok((RemoteLink::session_exec(channel), conn))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// that server is hosting**.
|
||||
///
|
||||
/// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction)
|
||||
/// header, which a client only writes after a user has answered the
|
||||
/// keep-or-restart prompt with "Restart Server". Nothing in the connect path
|
||||
/// calls this: an older daemon on the far side keeps serving, because it owns
|
||||
/// live work and only its owner can decide to throw that away.
|
||||
///
|
||||
/// Deliberately **not** an `ensure_remote_server` first. The mismatch that
|
||||
/// raises the prompt is discovered by an install pass that has already put
|
||||
/// this build's binary in place, so there is nothing left to install — and a
|
||||
/// second pass would rediscover the very mismatch the user is answering and
|
||||
/// relay a fresh prompt for it the moment the restart finished.
|
||||
pub async fn restart_remote_server(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
setup: &RouteSetup,
|
||||
) -> anyhow::Result<()> {
|
||||
let (conn, _reused) = self.open_connection(spec, &setup.broker).await?;
|
||||
// Blocking start to finish (SIGTERM, poll for the socket to go, launch,
|
||||
// poll for it to answer) and it may stop to ask the user for a password
|
||||
// on the way in — the same reason `open_remote_link` keeps the installer
|
||||
// off the runtime's workers.
|
||||
setup
|
||||
.blocking(move || crate::daemon::install::restart_remote_daemon(&conn))
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`open_remote_link`](Self::open_remote_link) for the daemon's std threads
|
||||
/// (the router runs on one). Safe from any thread that is not itself a
|
||||
/// runtime worker — the server's connection threads never are.
|
||||
pub fn open_remote_link_blocking(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
setup: &RouteSetup,
|
||||
server_command: Option<&str>,
|
||||
) -> anyhow::Result<(RemoteLink, Arc<SshConnection>)> {
|
||||
self.runtime
|
||||
.block_on(self.open_remote_link(spec, setup, server_command))
|
||||
}
|
||||
|
||||
/// Drop a connection key's registry slot so the next `open_connection` for it
|
||||
/// establishes a fresh connection instead of upgrading a stale `Weak`. Called
|
||||
/// by the self-healing reuse path when a reused connection turns out dead.
|
||||
@@ -552,6 +734,44 @@ async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell
|
||||
remote::parse_probe(&String::from_utf8_lossy(&out))
|
||||
}
|
||||
|
||||
/// Read the four environment variables the remote's control socket path is
|
||||
/// derived from, on a throwaway `exec` channel.
|
||||
///
|
||||
/// `None` when the remote said nothing usable — the caller then takes the
|
||||
/// `--stdio` bridge, which resolves the path in the process that binds it, so a
|
||||
/// failed probe costs a slower transport and never a failed connection.
|
||||
///
|
||||
/// stderr is folded in for the same reason the shell probe does it: the parse is
|
||||
/// marker-based and tolerates noise, and discarding a good answer because the
|
||||
/// remote's startup files complained would be gratuitous.
|
||||
async fn probe_remote_env(conn: &SshConnection) -> Option<remote_link::RemoteEnv> {
|
||||
let mut channel = conn.open_session_channel().await.ok()?;
|
||||
channel
|
||||
.exec(true, remote_link::REMOTE_ENV_PROBE)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let collect = async {
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => {
|
||||
out.extend_from_slice(&data);
|
||||
if out.len() >= PROBE_OUTPUT_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ChannelMsg::Eof | ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await;
|
||||
|
||||
let env = remote_link::RemoteEnv::parse_probe(&String::from_utf8_lossy(&out));
|
||||
(env != remote_link::RemoteEnv::default()).then_some(env)
|
||||
}
|
||||
|
||||
/// A conservative set of PTY modes for the shell channel — an interactive TTY
|
||||
/// with canonical input, echo, and signal handling on, and standard baud codes.
|
||||
/// The remote line discipline uses these as its starting point.
|
||||
@@ -621,6 +841,24 @@ mod tests {
|
||||
assert_eq!(a, ConnectionKey::from_spec(&base_spec()));
|
||||
}
|
||||
|
||||
/// `as_str` is what names a connection in prompts, logs and the installer's
|
||||
/// "which host am I writing to". It must carry the jump chain: two hosts
|
||||
/// reached through different bastions are different connections, and a
|
||||
/// label that collapsed them would put an install prompt on the wrong box.
|
||||
#[test]
|
||||
fn the_key_string_names_the_whole_chain() {
|
||||
assert_eq!(ConnectionKey::from_spec(&base_spec()).as_str(), "u@h:22");
|
||||
|
||||
let mut jumped = base_spec();
|
||||
let mut bastion = base_spec();
|
||||
bastion.host = "bastion".into();
|
||||
jumped.jump = Some(Box::new(bastion));
|
||||
assert_eq!(
|
||||
ConnectionKey::from_spec(&jumped).as_str(),
|
||||
"u@h:22|jump:u@bastion:22"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evict_connection_clears_the_registry_slot() {
|
||||
// The self-heal path evicts a dead connection's key so the next
|
||||
@@ -30,6 +30,7 @@ use russh::client::Msg;
|
||||
use russh::{Channel, ChannelMsg};
|
||||
|
||||
use crate::daemon::protocol::WinSize;
|
||||
use crate::daemon::remote_link::RemoteEntry;
|
||||
|
||||
use super::ConnectionKey;
|
||||
use super::forward::RemoteForwardTable;
|
||||
@@ -265,6 +266,17 @@ pub struct SshConnection {
|
||||
/// with no remote forwards.
|
||||
remote_forwards: RemoteForwardTable,
|
||||
alive: AtomicBool,
|
||||
/// How this host's `tty7-server` is reached — probed once, then reused by
|
||||
/// every remote workspace stream on this connection (design §7.1).
|
||||
///
|
||||
/// Per *connection*, not per channel: deciding costs a round trip (an `exec`
|
||||
/// to read the remote's environment, and on a host with
|
||||
/// `AllowStreamLocalForwarding no` a rejected channel open on top), and the
|
||||
/// answer cannot change while the connection lives. Behind a `tokio::Mutex`
|
||||
/// held across the probe, so two workspaces opening at once produce one
|
||||
/// probe rather than two — unlike [`super::SshManager`]'s shell-integration
|
||||
/// cache, where a duplicated probe is merely wasted work.
|
||||
remote_entry: tokio::sync::Mutex<Option<RemoteEntry>>,
|
||||
}
|
||||
|
||||
impl SshConnection {
|
||||
@@ -278,6 +290,7 @@ impl SshConnection {
|
||||
key,
|
||||
remote_forwards,
|
||||
alive: AtomicBool::new(true),
|
||||
remote_entry: tokio::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,6 +355,66 @@ impl SshConnection {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open a `direct-streamlocal@openssh.com` channel to `socket_path` on the
|
||||
/// remote — the preferred way into a remote `tty7-server` (design §7.1).
|
||||
///
|
||||
/// 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
|
||||
/// extension is OpenSSH's, and `AllowStreamLocalForwarding` defaults to
|
||||
/// `yes`; an administrator who set it to `no` makes this fail at channel
|
||||
/// open, which is the signal [`RemoteEntry`] caches a fallback for.
|
||||
///
|
||||
/// `socket_path` is the remote's path and is sent verbatim — no `~`, no
|
||||
/// variables, no client-side path arithmetic (a Windows client's
|
||||
/// `PathBuf::join` would corrupt it).
|
||||
pub async fn open_direct_streamlocal(
|
||||
&self,
|
||||
socket_path: &str,
|
||||
) -> Result<Channel<Msg>, russh::Error> {
|
||||
self.handle
|
||||
.lock()
|
||||
.await
|
||||
.channel_open_direct_streamlocal(socket_path.to_string())
|
||||
.await
|
||||
}
|
||||
|
||||
/// This connection's cached [`RemoteEntry`], probing with `init` the first
|
||||
/// time anyone asks.
|
||||
///
|
||||
/// The lock is held across `init` on purpose: the point of the cache is that
|
||||
/// the round trips happen once, and two workspaces opening simultaneously
|
||||
/// against a cold connection is the *normal* case (a window restoring its
|
||||
/// layout), not a rare race.
|
||||
pub async fn remote_entry_or_init<F, Fut>(&self, init: F) -> RemoteEntry
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = RemoteEntry>,
|
||||
{
|
||||
let mut guard = self.remote_entry.lock().await;
|
||||
if let Some(entry) = guard.as_ref() {
|
||||
return entry.clone();
|
||||
}
|
||||
let entry = init().await;
|
||||
log::debug!(
|
||||
"ssh {:?}: remote workspace entry is {}",
|
||||
self.key,
|
||||
entry.kind_label()
|
||||
);
|
||||
*guard = Some(entry.clone());
|
||||
entry
|
||||
}
|
||||
|
||||
/// Replace the cached entry after the preferred one failed in use.
|
||||
///
|
||||
/// A `direct-streamlocal` open that the server refuses is not necessarily
|
||||
/// visible at probe time — a daemon can be restarted, a socket removed, an
|
||||
/// administrator's `AllowStreamLocalForwarding no` applied on reload — so
|
||||
/// the first failure demotes the connection for good rather than letting
|
||||
/// every later stream pay the same rejected round trip.
|
||||
pub async fn set_remote_entry(&self, entry: RemoteEntry) {
|
||||
*self.remote_entry.lock().await = Some(entry);
|
||||
}
|
||||
|
||||
/// Request a `tcpip-forward` binding on `bind_host:bind_port`, routing incoming
|
||||
/// `forwarded-tcpip` channels to `target_host:target_port` (WS4 Remote forward).
|
||||
/// Registers the target *before* the request so an eager server channel finds
|
||||
@@ -375,6 +375,43 @@ impl SftpManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, creating or truncating it. Blocks the calling
|
||||
/// thread on the SSH runtime.
|
||||
///
|
||||
/// For callers that have the bytes in memory and no local file to stream
|
||||
/// from — the remote-server installer, which downloads a binary and pushes
|
||||
/// it — so they get the cached session and its retry-once-on-transport-
|
||||
/// failure behaviour instead of opening a channel of their own per write.
|
||||
///
|
||||
/// Chunked rather than one giant write so a ~6 MB binary is not a single
|
||||
/// SFTP message, and flushed *and* shut down before returning `Ok`: a
|
||||
/// server that runs out of disk reports it on the write or the close, and
|
||||
/// swallowing that would leave a truncated file for the caller to chmod and
|
||||
/// rename into place as though it were whole.
|
||||
pub fn put_bytes(
|
||||
&self,
|
||||
conn: &Arc<SshConnection>,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
SshManager::global().handle().block_on(async {
|
||||
self.with_session(conn, |sftp| async move {
|
||||
let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE;
|
||||
let mut file = sftp
|
||||
.open_with_flags(path.to_string(), flags)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
for chunk in bytes.chunks(CHUNK) {
|
||||
file.write_all(chunk).await.map_err(|e| format!("{e}"))?;
|
||||
}
|
||||
file.flush().await.map_err(|e| format!("{e}"))?;
|
||||
file.shutdown().await.map_err(|e| format!("{e}"))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a one-shot filesystem operation.
|
||||
pub fn op(&self, conn: &Arc<SshConnection>, op: &SftpOp) -> SftpOpResult {
|
||||
let result = SshManager::global().handle().block_on(async {
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Workspace-scoped control requests (design §15, M7).
|
||||
//!
|
||||
//! A *remote workspace* (design §2, "在上面开发") has no pane on this daemon: its
|
||||
//! panes live on the remote `tty7-server` and reach it through a routed byte
|
||||
//! pipe. What this side owns is the [`SshConnection`] that pipe rides — the same
|
||||
//! connection an SSH pane to that host would have used, deduplicated by
|
||||
//! [`ConnectionKey`].
|
||||
//!
|
||||
//! Everything a user wants from that connection — a port forward behind a
|
||||
//! ⌘-clicked `localhost:3000`, an SFTP download dragged out to Finder — is
|
||||
//! therefore addressable, just not by `pane_id`. This module is the one place
|
||||
//! that translates "which workspace" into "which connection", and it is
|
||||
//! deliberately the *only* new entry point: [`handle`] answers a whole
|
||||
//! [`WorkspaceRequest`] with a ready-to-send [`DaemonMsg`], so the daemon's
|
||||
//! dispatch grows one arm rather than nine.
|
||||
//!
|
||||
//! **It never connects.** [`SshManager::existing_connection`] is a lookup: a
|
||||
//! workspace request arrives on a short-lived control connection with nowhere to
|
||||
//! put an auth prompt, so "no connection" is reported as an error the GUI can
|
||||
//! show rather than a silent connect attempt that would hang on a passphrase.
|
||||
|
||||
use crate::core::session::WorkspaceId;
|
||||
use crate::daemon::protocol::{DaemonMsg, WorkspaceOp, WorkspaceRequest};
|
||||
|
||||
use super::SshManager;
|
||||
use super::sftp::SftpManager;
|
||||
|
||||
/// The bucket a workspace's SFTP transfer jobs are filed under.
|
||||
///
|
||||
/// [`SftpManager`] keys jobs by `pane_id`, and a remote workspace has no pane
|
||||
/// here to lend it one. Deriving the key from the workspace instead of using the
|
||||
/// requesting pane keeps a running download visible after the user switches the
|
||||
/// Files panel to another pane — the transfer belongs to the machine, not to
|
||||
/// whichever tab happened to start it.
|
||||
///
|
||||
/// The top bit is set so the synthetic key cannot collide with a real pane id:
|
||||
/// pane ids come from a counter that starts at 1, so a collision would need 2^63
|
||||
/// panes in one daemon.
|
||||
pub fn job_key(workspace: WorkspaceId) -> u64 {
|
||||
workspace.element_key() | (1 << 63)
|
||||
}
|
||||
|
||||
/// Answer one [`WorkspaceRequest`].
|
||||
///
|
||||
/// Every failure — an unknown/disconnected workspace, a refused bind, an SFTP
|
||||
/// error — comes back as [`DaemonMsg::Error`] with a sentence the GUI can show
|
||||
/// verbatim, because the caller has no other channel to explain itself on.
|
||||
pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
|
||||
let mgr = SshManager::global();
|
||||
let Some(conn) = mgr.existing_connection(&req.spec) else {
|
||||
// The workspace is not connected (or is mid-reconnect). Naming the host
|
||||
// matters: with several windows open the user needs to know *which* one
|
||||
// went away.
|
||||
return DaemonMsg::Error(format!(
|
||||
"workspace is not connected to {}@{}:{} — reconnect the window and try again",
|
||||
req.spec.user, req.spec.host, req.spec.port
|
||||
));
|
||||
};
|
||||
let ws = req.workspace;
|
||||
let view = req.view_pane;
|
||||
|
||||
match &req.op {
|
||||
WorkspaceOp::EnsureLoopback {
|
||||
remote_host,
|
||||
remote_port,
|
||||
} => match mgr.ensure_workspace_loopback(ws, conn, remote_host, *remote_port) {
|
||||
Ok(forward) => DaemonMsg::LoopbackForward(forward),
|
||||
Err(e) => DaemonMsg::Error(format!("forward failed: {e}")),
|
||||
},
|
||||
WorkspaceOp::AddForward { rule } => {
|
||||
DaemonMsg::ForwardList(mgr.add_workspace_forward(ws, view, conn, rule))
|
||||
}
|
||||
WorkspaceOp::RemoveForward { forward_id } => {
|
||||
DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id))
|
||||
}
|
||||
WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)),
|
||||
WorkspaceOp::TeardownForwards => {
|
||||
mgr.teardown_workspace_forwards(ws);
|
||||
DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view))
|
||||
}
|
||||
WorkspaceOp::SftpList { path } => match SftpManager::global().list(&conn, path) {
|
||||
Ok(entries) => DaemonMsg::SftpEntries(entries),
|
||||
Err(e) => DaemonMsg::Error(e),
|
||||
},
|
||||
WorkspaceOp::SftpOp { op } => DaemonMsg::SftpOpResult(SftpManager::global().op(&conn, op)),
|
||||
WorkspaceOp::SftpTransferStart { spec } => {
|
||||
// The caller's `pane_id` is overridden rather than trusted: a
|
||||
// workspace's jobs must land in the workspace's bucket, or
|
||||
// `SftpTransferList` below would not find them again.
|
||||
let mut spec = spec.clone();
|
||||
spec.pane_id = job_key(ws);
|
||||
match SftpManager::global().start_transfer(&conn, spec) {
|
||||
Ok(job_id) => DaemonMsg::SftpTransferStarted { job_id },
|
||||
Err(e) => DaemonMsg::Error(e),
|
||||
}
|
||||
}
|
||||
WorkspaceOp::SftpTransferList => {
|
||||
DaemonMsg::SftpTransferProgress(SftpManager::global().list_jobs(job_key(ws)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The synthetic job bucket is stable for a workspace, distinct between
|
||||
/// workspaces, and out of reach of any real pane id.
|
||||
#[test]
|
||||
fn job_key_is_stable_distinct_and_out_of_pane_range() {
|
||||
let a = WorkspaceId::new();
|
||||
let b = WorkspaceId::new();
|
||||
assert_eq!(job_key(a), job_key(a), "stable across calls");
|
||||
assert_ne!(job_key(a), job_key(b));
|
||||
// Real pane ids come from a counter starting at 1; none of them has the
|
||||
// top bit set, so the two spaces cannot overlap.
|
||||
assert!(job_key(a) >= 1 << 63);
|
||||
assert!(job_key(b) >= 1 << 63);
|
||||
}
|
||||
|
||||
/// A request naming a host this daemon has no connection to is refused with a
|
||||
/// message that names the host — never by silently connecting (which would
|
||||
/// need credentials this path cannot prompt for).
|
||||
#[test]
|
||||
fn request_without_a_live_connection_is_refused_by_name() {
|
||||
// Built through serde so the test states only the three fields it cares
|
||||
// about; every other field of `NativeSshSpec` has a serde default.
|
||||
let spec: crate::daemon::protocol::NativeSshSpec = serde_json::from_str(
|
||||
r#"{"host":"nowhere.invalid","port":2222,"user":"someone","auth_mode":"auto"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let req = WorkspaceRequest {
|
||||
workspace: WorkspaceId::new(),
|
||||
spec: Box::new(spec),
|
||||
view_pane: 3,
|
||||
op: WorkspaceOp::ListForwards,
|
||||
};
|
||||
match handle(&req) {
|
||||
DaemonMsg::Error(e) => {
|
||||
assert!(e.contains("someone@nowhere.invalid:2222"), "got: {e}");
|
||||
assert!(e.contains("not connected"), "got: {e}");
|
||||
}
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,14 +77,42 @@ mod imp_unix {
|
||||
if inline.as_os_str().as_bytes().len() <= MAX_SOCKET_PATH_BYTES {
|
||||
return inline;
|
||||
}
|
||||
// Prefer $XDG_RUNTIME_DIR (user-private, 0700 — the norm on Linux);
|
||||
// otherwise the OS temp dir, which is per-user on macOS.
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
let hash = fnv1a64(config_dir.as_os_str().as_bytes());
|
||||
base.join(format!("tty7-{hash:016x}.sock"))
|
||||
let name = format!("tty7-{hash:016x}.sock");
|
||||
let xdg = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(PathBuf::from);
|
||||
pick_fallback_socket(xdg.as_deref(), &std::env::temp_dir(), &name)
|
||||
}
|
||||
|
||||
/// The fallback path, given the two candidate bases. Split out from
|
||||
/// [`socket_path_for`] so it is testable without mutating the environment
|
||||
/// (which is `unsafe` in edition 2024 and races every other test).
|
||||
///
|
||||
/// Preference order is unchanged — `$XDG_RUNTIME_DIR` (user-private, 0700,
|
||||
/// the norm on Linux) before the OS temp dir (per-user on macOS) — so every
|
||||
/// path that works today is returned byte-for-byte as before and a live
|
||||
/// daemon is never orphaned. What is new is the length check: the "short"
|
||||
/// hashed name is only short *relative to the config dir*, and a deep
|
||||
/// `$XDG_RUNTIME_DIR` overruns `sun_path` just as readily. Without this,
|
||||
/// `bind` failed with "path must be shorter than SUN_LEN" and the daemon
|
||||
/// died at startup with no hint that the runtime dir was the cause.
|
||||
///
|
||||
/// If neither base fits, return the preferred one anyway: `bind` then
|
||||
/// reports the real path it rejected, which is a far better diagnostic than
|
||||
/// silently landing somewhere the peer will not look.
|
||||
pub(super) fn pick_fallback_socket(xdg: Option<&Path>, temp: &Path, name: &str) -> PathBuf {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
let fits = |p: &PathBuf| p.as_os_str().as_bytes().len() <= MAX_SOCKET_PATH_BYTES;
|
||||
let preferred = xdg.unwrap_or(temp).join(name);
|
||||
if fits(&preferred) {
|
||||
return preferred;
|
||||
}
|
||||
let temp_path = temp.join(name);
|
||||
if fits(&temp_path) {
|
||||
return temp_path;
|
||||
}
|
||||
preferred
|
||||
}
|
||||
|
||||
/// Path of the Unix-domain socket for this process's config dir. `None` only
|
||||
@@ -198,6 +226,7 @@ mod imp_unix {
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Pin the process config dir so the socket lives under a temp dir, never the
|
||||
/// real `~/.config`. First-call-wins; every IO test computes the same path.
|
||||
@@ -288,6 +317,47 @@ mod tests {
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A long `$XDG_RUNTIME_DIR` must not produce an over-long fallback. The
|
||||
/// hashed name is short relative to the *config dir*, not in absolute
|
||||
/// terms, so preferring the runtime dir unconditionally overran `sun_path`
|
||||
/// and killed the daemon at `bind` with no hint at the cause.
|
||||
#[test]
|
||||
fn a_long_runtime_dir_falls_through_to_the_temp_dir() {
|
||||
let name = "tty7-0123456789abcdef.sock";
|
||||
let long_xdg = PathBuf::from(format!("/run/user/1000/{}", "d".repeat(90)));
|
||||
let temp = PathBuf::from("/tmp");
|
||||
|
||||
let picked = imp_unix::pick_fallback_socket(Some(&long_xdg), &temp, name);
|
||||
assert_eq!(picked, temp.join(name), "falls through to the temp dir");
|
||||
|
||||
// The preference itself is untouched when the runtime dir does fit —
|
||||
// changing that would orphan every live daemon on a normal machine.
|
||||
let short_xdg = PathBuf::from("/run/user/1000");
|
||||
assert_eq!(
|
||||
imp_unix::pick_fallback_socket(Some(&short_xdg), &temp, name),
|
||||
short_xdg.join(name),
|
||||
"$XDG_RUNTIME_DIR still wins whenever it fits",
|
||||
);
|
||||
assert_eq!(
|
||||
imp_unix::pick_fallback_socket(None, &temp, name),
|
||||
temp.join(name),
|
||||
"no runtime dir means the temp dir, as before",
|
||||
);
|
||||
}
|
||||
|
||||
/// Neither base fits: return the preferred one so `bind` names the path it
|
||||
/// actually rejected, rather than silently landing where no peer looks.
|
||||
#[test]
|
||||
fn an_unusable_pair_of_bases_still_reports_the_preferred_path() {
|
||||
let name = "tty7-0123456789abcdef.sock";
|
||||
let long_xdg = PathBuf::from(format!("/run/{}", "d".repeat(90)));
|
||||
let long_temp = PathBuf::from(format!("/tmp/{}", "t".repeat(90)));
|
||||
assert_eq!(
|
||||
imp_unix::pick_fallback_socket(Some(&long_xdg), &long_temp, name),
|
||||
long_xdg.join(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,690 @@
|
||||
//! [`LocalHost`] — the [`Host`] that answers with `std::fs` and a `git`
|
||||
//! subprocess.
|
||||
//!
|
||||
//! This is the 99% path: every workspace whose files are on this machine holds
|
||||
//! one, and so does the `tty7-server` process serving a *remote* workspace to
|
||||
//! someone else's client. That second role is why it is written the way it is —
|
||||
//! blocking, allocation-frugal, and with every semantic decision (sort order,
|
||||
//! gitignore scoring, the search walk's bounds) made *here* rather than by the
|
||||
//! caller, so that a remote workspace gets byte-identical answers to a local
|
||||
//! one without the client and the server having to agree on anything but the
|
||||
//! wire.
|
||||
//!
|
||||
//! Two pieces of state, both about not repeating work:
|
||||
//!
|
||||
//! - the compiled `.gitignore` matchers, shared across every listing rather
|
||||
//! than shuttled to a worker and back the way the file tree used to before a
|
||||
//! host existed to own them;
|
||||
//! - nothing else. A host is otherwise a pure function of the filesystem.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
|
||||
use crate::core::git;
|
||||
use crate::core::gitignore::GitignoreChain;
|
||||
use crate::host::{
|
||||
Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, WatchHandle, WatchSub,
|
||||
guard_off_ui,
|
||||
};
|
||||
|
||||
/// How long changes are collected before a batch is delivered. Matched exactly
|
||||
/// by the remote implementation — see [`WatchSub::events`].
|
||||
const COALESCE_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// This machine's filesystem and git.
|
||||
pub struct LocalHost {
|
||||
/// Compiled `.gitignore` matchers, keyed by the directory each came from.
|
||||
///
|
||||
/// Behind an `Arc` as well as a `Mutex` so the watcher's coalescing thread
|
||||
/// can hold the same cache and clear it when a `.gitignore` is edited —
|
||||
/// which is the only thing that can invalidate a compiled matcher, and the
|
||||
/// watcher is the only place that finds out.
|
||||
gitignore: Arc<Mutex<GitignoreChain>>,
|
||||
}
|
||||
|
||||
impl LocalHost {
|
||||
/// A new local host.
|
||||
///
|
||||
/// Returns the trait object directly: nothing in the tree wants a concrete
|
||||
/// `LocalHost`, and handing one out would invite a call site to depend on
|
||||
/// something a remote host cannot do.
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new() -> SharedHost {
|
||||
Arc::new(LocalHost {
|
||||
gitignore: Arc::new(Mutex::new(GitignoreChain::default())),
|
||||
})
|
||||
}
|
||||
|
||||
/// The process-wide local host.
|
||||
///
|
||||
/// One instance, so the gitignore cache is shared by every local workspace
|
||||
/// instead of being recompiled per tab. Workspaces take their host from
|
||||
/// here rather than constructing their own.
|
||||
pub fn shared() -> SharedHost {
|
||||
static LOCAL: OnceLock<SharedHost> = OnceLock::new();
|
||||
LOCAL.get_or_init(LocalHost::new).clone()
|
||||
}
|
||||
|
||||
/// List `dir`, keeping each entry's full path — the shape `search` needs
|
||||
/// and `read_dir` throws away.
|
||||
fn list(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<(Entry, PathBuf)>> {
|
||||
// Two passes, because the second needs a lock the first must not hold.
|
||||
// The file tree asks for every root and every expanded directory in one
|
||||
// frame, so a dozen of these run at once; holding the shared matcher
|
||||
// cache across the `readdir` syscalls would serialize work that has no
|
||||
// reason to be serial.
|
||||
let mut out: Vec<(Entry, PathBuf)> = Vec::new();
|
||||
for e in fs::read_dir(dir)?.flatten() {
|
||||
let path = e.path();
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
// `DirEntry::file_type` is free on Unix (it comes out of `readdir`)
|
||||
// but does *not* follow links, and a link to a directory has to
|
||||
// read as a directory — that is what the tree expands and what the
|
||||
// sort puts first. So pay for the follow only on links.
|
||||
let ft = e.file_type().ok();
|
||||
let is_symlink = ft.is_some_and(|t| t.is_symlink());
|
||||
let is_dir = if is_symlink {
|
||||
// A broken link resolves to nothing: not a directory.
|
||||
fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false)
|
||||
} else {
|
||||
ft.is_some_and(|t| t.is_dir())
|
||||
};
|
||||
out.push((
|
||||
Entry {
|
||||
name,
|
||||
is_dir,
|
||||
is_symlink,
|
||||
ignored: false,
|
||||
},
|
||||
path,
|
||||
));
|
||||
}
|
||||
|
||||
// `.git` is ignored whatever the patterns say; everything else is scored
|
||||
// against the chain, and only when there is a root to bound it.
|
||||
let mut chain = self.gitignore.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (entry, path) in &mut out {
|
||||
entry.ignored = entry.name == ".git"
|
||||
|| root.is_some_and(|root| chain.is_ignored(path, entry.is_dir, root));
|
||||
}
|
||||
drop(chain);
|
||||
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories first, then case-insensitive by name.
|
||||
///
|
||||
/// Dotfiles keep their leading dot in that ordering, so they sort before
|
||||
/// letters — which is where users expect them, and what the file tree has
|
||||
/// always done.
|
||||
fn sort_entries(entries: &mut [(Entry, PathBuf)]) {
|
||||
entries.sort_by(|(a, _), (b, _)| {
|
||||
b.is_dir
|
||||
.cmp(&a.is_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
}
|
||||
|
||||
impl Host for LocalHost {
|
||||
fn id(&self) -> HostId {
|
||||
HostId::LOCAL
|
||||
}
|
||||
|
||||
fn separator(&self) -> char {
|
||||
std::path::MAIN_SEPARATOR
|
||||
}
|
||||
|
||||
fn join(&self, dir: &Path, name: &str) -> PathBuf {
|
||||
// Native semantics locally: `Path::join` already knows this platform's
|
||||
// rules, including the ones a separator alone doesn't capture.
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn is_absolute(&self, p: &Path) -> bool {
|
||||
p.is_absolute()
|
||||
}
|
||||
|
||||
fn read_dir(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<Entry>> {
|
||||
guard_off_ui();
|
||||
Ok(self.list(dir, root)?.into_iter().map(|(e, _)| e).collect())
|
||||
}
|
||||
|
||||
fn stat(&self, p: &Path) -> io::Result<Meta> {
|
||||
guard_off_ui();
|
||||
// `symlink_metadata` first: it answers `is_symlink` and, for the
|
||||
// overwhelmingly common non-link, is also the answer — one syscall
|
||||
// instead of two.
|
||||
let lmd = fs::symlink_metadata(p)?;
|
||||
let is_symlink = lmd.file_type().is_symlink();
|
||||
let md = if is_symlink { fs::metadata(p)? } else { lmd };
|
||||
Ok(Meta {
|
||||
is_dir: md.is_dir(),
|
||||
is_symlink,
|
||||
len: md.len(),
|
||||
mtime: md.modified().ok().map(MTime::from_system_time),
|
||||
readonly: md.permissions().readonly(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
|
||||
guard_off_ui();
|
||||
let md = fs::metadata(p)?;
|
||||
if md.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::IsADirectory,
|
||||
format!("{} is a directory", p.display()),
|
||||
));
|
||||
}
|
||||
// Checked before reading, not after: the whole point of the limit is
|
||||
// that an oversized file is never carried anywhere.
|
||||
if md.len() > max_bytes {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::FileTooLarge,
|
||||
format!(
|
||||
"{} is {} bytes, over the {max_bytes} limit",
|
||||
p.display(),
|
||||
md.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
fs::read(p)
|
||||
}
|
||||
|
||||
fn canonicalize(&self, p: &Path) -> io::Result<PathBuf> {
|
||||
guard_off_ui();
|
||||
fs::canonicalize(p)
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &str,
|
||||
limit: usize,
|
||||
max_dirs: usize,
|
||||
show_hidden: bool,
|
||||
) -> io::Result<Vec<SearchHit>> {
|
||||
guard_off_ui();
|
||||
let needle = query.to_lowercase();
|
||||
let mut out: Vec<SearchHit> = Vec::new();
|
||||
// Shared across roots: the budget bounds the *search*, not each root,
|
||||
// so a workspace with six roots cannot walk six times as far.
|
||||
let mut visited = 0usize;
|
||||
for root in roots {
|
||||
// A deque, not a `Vec` with `remove(0)`: the frontier of a wide tree
|
||||
// gets long and shifting it down per pop is quadratic.
|
||||
let mut queue: VecDeque<PathBuf> = VecDeque::from([root.clone()]);
|
||||
while let Some(dir) = queue.pop_front() {
|
||||
if out.len() >= limit || visited >= max_dirs {
|
||||
break;
|
||||
}
|
||||
visited += 1;
|
||||
// An unreadable directory is skipped, not fatal — a search that
|
||||
// aborted on the first permission-denied subdirectory would be
|
||||
// useless on any real machine.
|
||||
let Ok(entries) = self.list(&dir, Some(root)) else {
|
||||
continue;
|
||||
};
|
||||
for (e, path) in entries {
|
||||
// `.git`, `target`, `node_modules`: where the file count
|
||||
// explodes and never where anyone is searching. Skipping
|
||||
// them is what keeps the directory budget meaningful.
|
||||
if !show_hidden && (e.ignored || e.name.starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
if e.is_dir {
|
||||
queue.push_back(path.clone());
|
||||
}
|
||||
if e.name.to_lowercase().contains(&needle) {
|
||||
out.push(SearchHit {
|
||||
name: e.name,
|
||||
path,
|
||||
is_dir: e.is_dir,
|
||||
ignored: e.ignored,
|
||||
});
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
guard_off_ui();
|
||||
fs::write(p, bytes)?;
|
||||
// Stat immediately after, on the same thread that just wrote: the
|
||||
// remote peer answers from the same place, so both hosts report the
|
||||
// metadata the write itself produced rather than whatever a later
|
||||
// caller happens to observe.
|
||||
self.stat(p)
|
||||
}
|
||||
|
||||
fn create_file_new(&self, p: &Path) -> io::Result<()> {
|
||||
guard_off_ui();
|
||||
fs::File::create_new(p).map(|_| ())
|
||||
}
|
||||
|
||||
fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()> {
|
||||
guard_off_ui();
|
||||
if recursive {
|
||||
fs::create_dir_all(p)
|
||||
} else {
|
||||
fs::create_dir(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
guard_off_ui();
|
||||
// `fs::rename` overwrites silently on Unix, and this API promises it
|
||||
// doesn't. `symlink_metadata` rather than `exists` so a dangling
|
||||
// symlink at the destination still counts as occupied — clobbering one
|
||||
// would destroy a link the user can see in the tree.
|
||||
if fs::symlink_metadata(to).is_ok() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists", to.display()),
|
||||
));
|
||||
}
|
||||
fs::rename(from, to)
|
||||
}
|
||||
|
||||
fn remove(&self, p: &Path, recursive: bool) -> io::Result<()> {
|
||||
guard_off_ui();
|
||||
// `symlink_metadata`, so a symlink pointing at a directory is unlinked
|
||||
// rather than recursed into — deleting a link must never delete what it
|
||||
// points at.
|
||||
let md = fs::symlink_metadata(p)?;
|
||||
if md.is_dir() {
|
||||
if recursive {
|
||||
fs::remove_dir_all(p)
|
||||
} else {
|
||||
fs::remove_dir(p)
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_root(&self, p: &Path) -> io::Result<Option<PathBuf>> {
|
||||
guard_off_ui();
|
||||
// `.git` is a directory in a normal checkout and a *file* in a linked
|
||||
// worktree, so test for existence rather than for a directory.
|
||||
Ok(p.ancestors()
|
||||
.find(|a| a.join(".git").exists())
|
||||
.map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
guard_off_ui();
|
||||
git::git_output(cwd, args)
|
||||
}
|
||||
|
||||
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub> {
|
||||
guard_off_ui();
|
||||
local_watch(dirs, Arc::clone(&self.gitignore))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The watched set, in both the form the caller gave and the form the platform
|
||||
/// reports events in.
|
||||
///
|
||||
/// macOS' FSEvents canonicalizes: a watch on `/var/folders/…` reports
|
||||
/// `/private/var/folders/…`. Without the second form every event would look
|
||||
/// like it came from somewhere unwatched; without the first, callers would get
|
||||
/// back paths they cannot match against the ones they asked about. So both are
|
||||
/// kept and events are rewritten into the caller's vocabulary on the way out.
|
||||
#[derive(Default)]
|
||||
struct WatchedDirs {
|
||||
/// Canonical form → the form the caller used.
|
||||
by_canonical: HashMap<PathBuf, PathBuf>,
|
||||
/// Exactly what the caller asked for, for `set_dirs` diffing.
|
||||
given: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl WatchedDirs {
|
||||
/// The caller-facing path for an event on `p`, or `None` when `p` is not in
|
||||
/// (or directly under) a watched directory.
|
||||
///
|
||||
/// This filter is what makes the subscription non-recursive regardless of
|
||||
/// backend: FSEvents is inherently recursive and notify only filters on a
|
||||
/// best-effort basis, so the guarantee is enforced here rather than
|
||||
/// assumed.
|
||||
fn translate(&self, p: &Path) -> Option<PathBuf> {
|
||||
if let Some(parent) = p.parent()
|
||||
&& let Some(given) = self.by_canonical.get(parent)
|
||||
{
|
||||
return match p.file_name() {
|
||||
Some(name) => Some(given.join(name)),
|
||||
None => Some(given.clone()),
|
||||
};
|
||||
}
|
||||
// The watched directory itself (created, removed, renamed).
|
||||
self.by_canonical.get(p).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// A live local watch: the notify watcher plus the set it is following.
|
||||
struct LocalWatch {
|
||||
inner: Mutex<LocalWatchInner>,
|
||||
}
|
||||
|
||||
struct LocalWatchInner {
|
||||
watcher: notify::RecommendedWatcher,
|
||||
dirs: Arc<Mutex<WatchedDirs>>,
|
||||
}
|
||||
|
||||
impl WatchHandle for LocalWatch {
|
||||
fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()> {
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let want: HashSet<PathBuf> = dirs.iter().cloned().collect();
|
||||
let LocalWatchInner { watcher, dirs } = &mut *inner;
|
||||
let mut set = dirs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
for gone in set.given.difference(&want) {
|
||||
let _ = watcher.unwatch(gone);
|
||||
}
|
||||
let added: Vec<PathBuf> = want.difference(&set.given).cloned().collect();
|
||||
// Rebuild rather than patch: `by_canonical` is keyed by a form we do not
|
||||
// hold the inverse of, and the set is at most a few dozen expanded
|
||||
// directories.
|
||||
set.by_canonical.clear();
|
||||
for d in &added {
|
||||
// A directory that has just been deleted is not an error worth
|
||||
// failing the whole re-subscription over — the next listing will
|
||||
// notice it is gone.
|
||||
let _ = watcher.watch(d, RecursiveMode::NonRecursive);
|
||||
}
|
||||
for d in &want {
|
||||
let canon = fs::canonicalize(d).unwrap_or_else(|_| d.clone());
|
||||
set.by_canonical.insert(canon, d.clone());
|
||||
set.by_canonical.insert(d.clone(), d.clone());
|
||||
}
|
||||
set.given = want;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a watch over `dirs`, coalescing events into 100ms batches.
|
||||
///
|
||||
/// `gitignore` is cleared whenever a batch contains a `.gitignore`, which is the
|
||||
/// only event that can invalidate a compiled matcher. Doing it here rather than
|
||||
/// asking callers to remember means a remote client gets the same invalidation
|
||||
/// for free: the server's own host is the one watching.
|
||||
fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::Result<WatchSub> {
|
||||
let (raw_tx, raw_rx) = std::sync::mpsc::channel::<Vec<PathBuf>>();
|
||||
let (batch_tx, batch_rx) = smol::channel::unbounded::<Vec<PathBuf>>();
|
||||
let watched: Arc<Mutex<WatchedDirs>> = Arc::new(Mutex::new(WatchedDirs::default()));
|
||||
|
||||
let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if let Ok(ev) = res
|
||||
&& !ev.paths.is_empty()
|
||||
{
|
||||
// A closed receiver means the subscription was dropped; the watcher
|
||||
// is on its way out too, so there is nothing to report.
|
||||
let _ = raw_tx.send(ev.paths);
|
||||
}
|
||||
})
|
||||
.map_err(notify_to_io)?;
|
||||
|
||||
let handle = LocalWatch {
|
||||
inner: Mutex::new(LocalWatchInner {
|
||||
watcher,
|
||||
dirs: Arc::clone(&watched),
|
||||
}),
|
||||
};
|
||||
handle.set_dirs(dirs)?;
|
||||
|
||||
// The coalescer. It ends when the watcher is dropped: that drops the event
|
||||
// closure, which drops `raw_tx`, which disconnects this receiver.
|
||||
std::thread::Builder::new()
|
||||
.name("tty7-host-watch".into())
|
||||
.spawn(move || coalesce(raw_rx, batch_tx, watched, gitignore))
|
||||
.map_err(|e| io::Error::other(format!("watch thread: {e}")))?;
|
||||
|
||||
Ok(WatchSub::new(batch_rx, Box::new(handle)))
|
||||
}
|
||||
|
||||
/// Collect raw events into deduplicated 100ms batches.
|
||||
///
|
||||
/// The window exists so that a `cargo build` touching ten thousand files is one
|
||||
/// repaint rather than ten thousand, and it is identical on the remote side so
|
||||
/// that where the files live cannot change how the tree behaves.
|
||||
fn coalesce(
|
||||
raw_rx: std::sync::mpsc::Receiver<Vec<PathBuf>>,
|
||||
batch_tx: smol::channel::Sender<Vec<PathBuf>>,
|
||||
watched: Arc<Mutex<WatchedDirs>>,
|
||||
gitignore: Arc<Mutex<GitignoreChain>>,
|
||||
) {
|
||||
loop {
|
||||
// Block until something happens at all — an idle watch costs nothing.
|
||||
let Ok(first) = raw_rx.recv() else { return };
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
let mut batch: Vec<PathBuf> = Vec::new();
|
||||
let take = |paths: Vec<PathBuf>, batch: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>| {
|
||||
let set = watched.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for p in paths {
|
||||
if let Some(translated) = set.translate(&p)
|
||||
&& seen.insert(translated.clone())
|
||||
{
|
||||
batch.push(translated);
|
||||
}
|
||||
}
|
||||
};
|
||||
take(first, &mut batch, &mut seen);
|
||||
|
||||
let deadline = Instant::now() + COALESCE_WINDOW;
|
||||
loop {
|
||||
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
|
||||
break;
|
||||
};
|
||||
match raw_rx.recv_timeout(left) {
|
||||
Ok(paths) => take(paths, &mut batch, &mut seen),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
|
||||
// Watcher gone: deliver what we have, then stop.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if !batch.is_empty() {
|
||||
let _ = batch_tx.send_blocking(batch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if batch.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A `.gitignore` edit changes the answer for every path under it, so the
|
||||
// compiled matchers all go. Cheap: they recompile lazily, per directory,
|
||||
// on the next listing that needs one.
|
||||
if batch
|
||||
.iter()
|
||||
.any(|p| p.file_name().is_some_and(|n| n == ".gitignore"))
|
||||
{
|
||||
gitignore.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
}
|
||||
if batch_tx.send_blocking(batch).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// notify's error type carries an `io::Error` for the cases that have one; the
|
||||
/// rest become `Other` with the message preserved.
|
||||
fn notify_to_io(e: notify::Error) -> io::Error {
|
||||
match e.kind {
|
||||
notify::ErrorKind::Io(io) => io,
|
||||
other => io::Error::other(format!("watch: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::conformance::Sandbox;
|
||||
|
||||
/// A temp directory that satisfies the conformance sandbox contract.
|
||||
struct TempSandbox(tempfile::TempDir);
|
||||
|
||||
impl Sandbox for TempSandbox {
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
|
||||
fn symlink(&self, target: &Path, link: &Path) -> Option<io::Result<()>> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(std::os::unix::fs::symlink(target, link))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Windows needs a privilege we cannot assume in CI; the cases
|
||||
// that want a symlink skip instead of failing.
|
||||
let _ = (target, link);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox() -> (SharedHost, TempSandbox) {
|
||||
(
|
||||
LocalHost::new(),
|
||||
TempSandbox(tempfile::TempDir::new().unwrap()),
|
||||
)
|
||||
}
|
||||
|
||||
// Every case in the shared suite, run against `LocalHost`. `RemoteHost` and
|
||||
// the stdio server run the identical list; a divergence between them is
|
||||
// exactly what this exists to catch.
|
||||
crate::host_conformance_suite!(local, sandbox);
|
||||
|
||||
/// The sort is the file tree's, verbatim: directories first, then
|
||||
/// case-insensitively by name, with dotfiles keeping their leading dot (so
|
||||
/// `.gitignore` sorts before `main.rs`).
|
||||
#[test]
|
||||
fn sort_matches_the_file_trees_order() {
|
||||
let mut v: Vec<(Entry, PathBuf)> = ["main.rs", "Cargo.toml", ".gitignore", "src", "Zeta"]
|
||||
.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
Entry {
|
||||
name: (*n).to_string(),
|
||||
is_dir: *n == "src",
|
||||
is_symlink: false,
|
||||
ignored: false,
|
||||
},
|
||||
PathBuf::from(n),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort_entries(&mut v);
|
||||
let names: Vec<&str> = v.iter().map(|(e, _)| e.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["src", ".gitignore", "Cargo.toml", "main.rs", "Zeta"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The process-wide host is one instance, so every local workspace shares
|
||||
/// the gitignore cache rather than recompiling per tab.
|
||||
#[test]
|
||||
fn shared_is_a_singleton() {
|
||||
assert!(Arc::ptr_eq(&LocalHost::shared(), &LocalHost::shared()));
|
||||
assert!(LocalHost::shared().id().is_local());
|
||||
}
|
||||
|
||||
/// The gitignore chain the file tree used to hand back and forth now lives
|
||||
/// in the host — and scores the same fixture the same way: deepest match
|
||||
/// wins, `!` un-ignores, `.git` is ignored whatever the patterns say.
|
||||
#[test]
|
||||
fn gitignore_chain_scores_the_file_tree_fixture() {
|
||||
let (h, tmp) = sandbox();
|
||||
let root = tmp.path();
|
||||
h.create_dir(&root.join(".git"), false).unwrap();
|
||||
h.create_dir(&root.join("src"), false).unwrap();
|
||||
h.write_file(&root.join(".gitignore"), b"*.log\nbuild/\n")
|
||||
.unwrap();
|
||||
h.write_file(&root.join("src/.gitignore"), b"!keep.log\n")
|
||||
.unwrap();
|
||||
h.write_file(&root.join("drop.log"), b"").unwrap();
|
||||
h.write_file(&root.join("src/keep.log"), b"").unwrap();
|
||||
h.write_file(&root.join("src/main.rs"), b"").unwrap();
|
||||
|
||||
let ignored = |entries: &[Entry], name: &str| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.unwrap_or_else(|| panic!("{name} missing"))
|
||||
.ignored
|
||||
};
|
||||
let top = h.read_dir(root, Some(root)).unwrap();
|
||||
assert!(ignored(&top, "drop.log"));
|
||||
assert!(ignored(&top, ".git"));
|
||||
assert!(!ignored(&top, "src"));
|
||||
|
||||
let nested = h.read_dir(&root.join("src"), Some(root)).unwrap();
|
||||
assert!(!ignored(&nested, "keep.log"), "whitelist un-ignores");
|
||||
assert!(!ignored(&nested, "main.rs"));
|
||||
|
||||
// And the search agrees with the listing: the ignored `.log` stays out,
|
||||
// the whitelisted one comes back.
|
||||
let hits = h
|
||||
.search(&[root.to_path_buf()], "log", 200, 2000, false)
|
||||
.unwrap();
|
||||
let names: Vec<&str> = hits.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["keep.log"]);
|
||||
}
|
||||
|
||||
/// Editing a `.gitignore` has to change the answer, and the only thing that
|
||||
/// finds out is the watcher — so the invalidation rides along with it.
|
||||
#[test]
|
||||
fn a_gitignore_edit_through_the_watcher_clears_the_cache() {
|
||||
let (h, tmp) = sandbox();
|
||||
let root = tmp.path().to_path_buf();
|
||||
h.write_file(&root.join(".gitignore"), b"*.log\n").unwrap();
|
||||
h.write_file(&root.join("a.log"), b"").unwrap();
|
||||
let listed = h.read_dir(&root, Some(&root)).unwrap();
|
||||
assert!(listed.iter().any(|e| e.name == "a.log" && e.ignored));
|
||||
|
||||
let sub = h.watch(&[root.clone()]).unwrap();
|
||||
|
||||
// Poll rather than block on the channel, and re-write each round.
|
||||
// FSEvents registers asynchronously, so a write landing in the first
|
||||
// few milliseconds after `watch` can simply never be reported — and a
|
||||
// test that blocked waiting for that event would hang forever rather
|
||||
// than fail.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
let mut cleared = false;
|
||||
while Instant::now() < deadline {
|
||||
h.write_file(&root.join(".gitignore"), b"# nothing\n")
|
||||
.unwrap();
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
while sub.events().try_recv().is_ok() {}
|
||||
if h.read_dir(&root, Some(&root))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e.name == "a.log" && !e.ignored)
|
||||
{
|
||||
cleared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
cleared,
|
||||
"a `.gitignore` change seen by the watcher must drop the compiled matchers"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
//! [`Host`]: the machine a workspace's files and git live on.
|
||||
//!
|
||||
//! Every filesystem read, every write, every `git` shell-out and every file
|
||||
//! watch tty7 performs on behalf of a workspace goes through this one trait, so
|
||||
//! that "the files are on this laptop" and "the files are on a box in another
|
||||
//! datacentre" differ by which `Arc<dyn Host>` the workspace holds and by
|
||||
//! nothing else. [`local::LocalHost`] is the implementation that answers with
|
||||
//! `std::fs`; `host::remote::RemoteHost` answers over the control connection;
|
||||
//! and both are checked against the same [`conformance`] suite, because a
|
||||
//! difference between them is a bug that only shows up on someone else's
|
||||
//! machine.
|
||||
//!
|
||||
//! # Blocking on purpose
|
||||
//!
|
||||
//! Every method blocks. That is a decision, not an oversight
|
||||
//! (`docs/2026-07-27-remote-workspace-impl-contract.md` §1): the trait has to be
|
||||
//! 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
|
||||
//! `&mut Context<T>` cannot be held across an `.await` anyway — so making the
|
||||
//! trait async would box every `LocalHost::stat` (the 99% path) without saving
|
||||
//! a single call site from being restructured.
|
||||
//!
|
||||
//! The consequence is a rule: **no `Host` method may be called on the UI
|
||||
//! thread.** The GUI reaches a host only through `ui::host_ops::HostOps`, which
|
||||
//! does the `spawn` → `background_spawn` → `update` dance. [`guard_off_ui`]
|
||||
//! turns a violation into a debug-build panic at the call site rather than a
|
||||
//! dropped frame nobody can attribute.
|
||||
//!
|
||||
//! # Paths belong to the host, not to `std::path`
|
||||
//!
|
||||
//! A Windows client talking to a Linux host has to build `/home/me/src`, but
|
||||
//! `PathBuf::join` would give it `/home/me\src` and `Path::is_absolute` would
|
||||
//! call `/home/me` relative. So path arithmetic that crosses the boundary goes
|
||||
//! through [`Host::join`] and [`Host::is_absolute`], which answer with the
|
||||
//! *host's* semantics. `parent`, `file_name`, `starts_with` and friends are
|
||||
//! fine as-is — Windows' `std::path` already treats `/` as a separator.
|
||||
|
||||
pub mod conformance;
|
||||
pub mod local;
|
||||
pub mod remote;
|
||||
pub mod server;
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::thread::ThreadId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A stable, in-process identifier for one `Arc<dyn Host>`.
|
||||
///
|
||||
/// **Never persisted.** It exists so that structures which cannot hold an
|
||||
/// `Arc<dyn Host>` — the git-status cache's path tables, pane records, the
|
||||
/// in-flight maps — can still say *which* machine a path belongs to, and so
|
||||
/// that two identical paths on two different machines never collide in one map.
|
||||
///
|
||||
/// [`HostId::LOCAL`] is `0` and always means this machine. Remote ids are
|
||||
/// derived from the **connection**, not the workspace: several workspaces on
|
||||
/// one remote box share an id, matching the granularity at which the SSH
|
||||
/// connection itself is shared.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
|
||||
pub struct HostId(pub u64);
|
||||
|
||||
impl HostId {
|
||||
/// This machine. Reserved: no derivation ever produces it.
|
||||
pub const LOCAL: HostId = HostId(0);
|
||||
|
||||
/// Derive an id from a normalized connection key.
|
||||
///
|
||||
/// `key` must be the canonical connection string for the machine
|
||||
/// (`ssh-profile:<uuid>`, `ssh-alias:<alias>`, `ssh-direct:<user>@<host>:<port>`,
|
||||
/// `wsl:<distro>`) so that two references to the same box always hash the
|
||||
/// same. A hash of exactly `0` is bumped to `1`, because `0` is local's.
|
||||
pub fn from_connection_key(key: &str) -> HostId {
|
||||
let h = fnv1a64(key.as_bytes());
|
||||
HostId(if h == 0 { 1 } else { h })
|
||||
}
|
||||
|
||||
/// Whether this is [`HostId::LOCAL`].
|
||||
pub fn is_local(self) -> bool {
|
||||
self == HostId::LOCAL
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic 64-bit FNV-1a — the same one `daemon::transport` keys its
|
||||
/// fallback socket path with.
|
||||
///
|
||||
/// Not `DefaultHasher`: ids derived here are compared against ids derived by a
|
||||
/// *different build* of tty7 (a daemon outlives an app upgrade; a remote server
|
||||
/// is its own binary), so the function has to be stable across compiler and
|
||||
/// std versions, which `DefaultHasher` explicitly is not.
|
||||
pub fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in bytes {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The UI-thread guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static UI_THREAD: OnceLock<ThreadId> = OnceLock::new();
|
||||
|
||||
/// Record the calling thread as the UI thread, so [`guard_off_ui`] has
|
||||
/// something to compare against. Idempotent; later calls are ignored.
|
||||
///
|
||||
/// `ui::host_ops` calls this on its way through, which is enough: everything it
|
||||
/// runs on runs on the UI thread by construction, and until it is called the
|
||||
/// guard simply never fires (a headless `tty7-server` has no UI thread and
|
||||
/// wants none of this).
|
||||
pub fn register_ui_thread() {
|
||||
let _ = UI_THREAD.set(std::thread::current().id());
|
||||
}
|
||||
|
||||
/// Whether the calling thread is the one [`register_ui_thread`] claimed.
|
||||
pub fn is_ui_thread() -> bool {
|
||||
UI_THREAD
|
||||
.get()
|
||||
.is_some_and(|t| *t == std::thread::current().id())
|
||||
}
|
||||
|
||||
/// Panic (debug builds only) if a blocking `Host` call is happening on the UI
|
||||
/// thread.
|
||||
///
|
||||
/// Deliberately not `#[cfg(debug_assertions)]` on the *function* — call sites
|
||||
/// would then need their own `cfg`, and one forgotten `cfg` is one unguarded
|
||||
/// method. `debug_assert!` already compiles the check away in release.
|
||||
#[inline]
|
||||
pub fn guard_off_ui() {
|
||||
debug_assert!(
|
||||
!is_ui_thread(),
|
||||
"Host call on the UI thread — route it through ui::host_ops::HostOps"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One entry of a directory listing.
|
||||
///
|
||||
/// **No `path` field, on purpose.** The caller rebuilds it with
|
||||
/// [`Host::join`]: a remote entry's path uses the *remote's* separator, and a
|
||||
/// `PathBuf` assembled on a Windows client would use a backslash the remote has
|
||||
/// never heard of.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Entry {
|
||||
/// The file name, lossily decoded when the host's filesystem holds bytes
|
||||
/// that are not valid UTF-8.
|
||||
pub name: String,
|
||||
/// Whether the entry *resolves to* a directory — symlinks followed, so a
|
||||
/// link to a directory is `true` here and `true` in `is_symlink` both.
|
||||
pub is_dir: bool,
|
||||
/// Whether the entry is itself a symbolic link.
|
||||
pub is_symlink: bool,
|
||||
/// The host's own gitignore verdict, computed against the chain of
|
||||
/// `.gitignore` files from the listing's `root` down. `.git` is always
|
||||
/// `true`. Always `false` when the listing had no `root`, or when the host
|
||||
/// has no git.
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
/// What a `stat` answers.
|
||||
///
|
||||
/// Deliberately not `std::fs::Metadata`, which can be neither constructed nor
|
||||
/// serialized — a remote host has to be able to hand one across a wire.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Meta {
|
||||
/// Whether the path resolves to a directory (symlinks followed).
|
||||
pub is_dir: bool,
|
||||
/// Whether the path is itself a symbolic link.
|
||||
pub is_symlink: bool,
|
||||
/// Size in bytes.
|
||||
pub len: u64,
|
||||
/// `None` when the platform or filesystem has no modification time.
|
||||
pub mtime: Option<MTime>,
|
||||
/// Whether the permission bits say read-only.
|
||||
pub readonly: bool,
|
||||
}
|
||||
|
||||
/// A modification time, to the nanosecond.
|
||||
///
|
||||
/// Nanoseconds rather than milliseconds because the code editor detects
|
||||
/// external edits by asking "is the mtime still the one I wrote?" — at
|
||||
/// millisecond granularity a real edit landing in the same millisecond as our
|
||||
/// own write is indistinguishable from our own write, and gets swallowed.
|
||||
/// Two fields rather than a `u128` because JSON cannot carry a `u128` without
|
||||
/// losing precision, and this type crosses a JSON wire.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct MTime {
|
||||
/// Whole seconds since the Unix epoch; negative before 1970.
|
||||
pub secs: i64,
|
||||
/// Nanoseconds within the second, `0..1_000_000_000`.
|
||||
pub nanos: u32,
|
||||
}
|
||||
|
||||
impl MTime {
|
||||
/// Convert from a `SystemTime`, keeping pre-epoch times exact rather than
|
||||
/// clamping them to zero.
|
||||
pub fn from_system_time(t: std::time::SystemTime) -> MTime {
|
||||
match t.duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(d) => MTime {
|
||||
secs: d.as_secs() as i64,
|
||||
nanos: d.subsec_nanos(),
|
||||
},
|
||||
Err(e) => {
|
||||
// Before the epoch: `duration_since` hands back how far before.
|
||||
let d = e.duration();
|
||||
let (secs, nanos) = if d.subsec_nanos() == 0 {
|
||||
(-(d.as_secs() as i64), 0)
|
||||
} else {
|
||||
(-(d.as_secs() as i64) - 1, 1_000_000_000 - d.subsec_nanos())
|
||||
};
|
||||
MTime { secs, nanos }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one child-process run produced.
|
||||
///
|
||||
/// Deliberately not `std::process::Output`: its `ExitStatus` cannot be
|
||||
/// constructed portably, and a remote host has to synthesize one from a wire
|
||||
/// message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Output {
|
||||
/// The exit code, or `None` when the process was killed by a signal or the
|
||||
/// code could not be obtained.
|
||||
pub status: Option<i32>,
|
||||
/// Raw stdout, base64 on the wire.
|
||||
///
|
||||
/// Not a plain `Vec<u8>`: `serde_json` has no byte type and renders one as
|
||||
/// an array of decimal numbers, so a 1 MB `git diff` would cross the wire as
|
||||
/// roughly 4 MB of JSON. (`serde_bytes` does not help here — it forwards to
|
||||
/// `serialize_bytes`, which `serde_json` implements as exactly that array.)
|
||||
#[serde(with = "b64")]
|
||||
pub stdout: Vec<u8>,
|
||||
/// Raw stderr, same encoding.
|
||||
#[serde(with = "b64")]
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `Vec<u8>` ⇄ base64 string, for the byte fields that cross a JSON wire.
|
||||
mod b64 {
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::{Deserialize as _, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
STANDARD.decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl Output {
|
||||
/// Exited zero.
|
||||
pub fn success(&self) -> bool {
|
||||
self.status == Some(0)
|
||||
}
|
||||
|
||||
/// stdout as lossy UTF-8, trimmed — the shape every git call site wants.
|
||||
pub fn stdout_trimmed(&self) -> String {
|
||||
String::from_utf8_lossy(&self.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
/// stderr as lossy UTF-8, trimmed — the shape error messages want.
|
||||
pub fn stderr_trimmed(&self) -> String {
|
||||
String::from_utf8_lossy(&self.stderr).trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// One hit from [`Host::search`]. Unlike [`Entry`] this *does* carry a path:
|
||||
/// hits come from directories the caller never listed, so there is nothing to
|
||||
/// join against — the host, which knows its own separator, builds it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SearchHit {
|
||||
/// The file name that matched.
|
||||
pub name: String,
|
||||
/// The absolute path, in the host's own separator.
|
||||
pub path: PathBuf,
|
||||
/// Whether the hit is a directory.
|
||||
pub is_dir: bool,
|
||||
/// Whether the hit is gitignored.
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A live subscription to filesystem changes.
|
||||
///
|
||||
/// Long-lived and *mutable*: the file tree changes which directories it cares
|
||||
/// about every time a row is expanded, and tearing the subscription down and
|
||||
/// rebuilding it would cost a round trip plus a rebuilt server-side watcher for
|
||||
/// every disclosure triangle. So the subscription outlives the set, and
|
||||
/// [`WatchSub::set_dirs`] replaces the set in place.
|
||||
///
|
||||
/// Dropping it unsubscribes.
|
||||
pub struct WatchSub {
|
||||
rx: smol::channel::Receiver<Vec<PathBuf>>,
|
||||
inner: Box<dyn WatchHandle>,
|
||||
}
|
||||
|
||||
impl WatchSub {
|
||||
/// Build a subscription from its two halves. Implementations of
|
||||
/// [`Host::watch`] call this; nothing else needs to.
|
||||
pub fn new(rx: smol::channel::Receiver<Vec<PathBuf>>, inner: Box<dyn WatchHandle>) -> WatchSub {
|
||||
WatchSub { rx, inner }
|
||||
}
|
||||
|
||||
/// The batched event stream.
|
||||
///
|
||||
/// Batches are coalesced over a 100ms window and deduplicated within it —
|
||||
/// **by every implementation, identically**. A local watcher is not allowed
|
||||
/// to be "helpfully" more immediate than a remote one, because then the
|
||||
/// consumer's idea of how often it repaints would depend on where the files
|
||||
/// happen to live.
|
||||
pub fn events(&self) -> &smol::channel::Receiver<Vec<PathBuf>> {
|
||||
&self.rx
|
||||
}
|
||||
|
||||
/// Replace the watched set wholesale. The implementation works out the
|
||||
/// difference; the caller only ever states the full set.
|
||||
///
|
||||
/// Always **non-recursive**: a directory being watched says nothing about
|
||||
/// its subdirectories.
|
||||
pub fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()> {
|
||||
self.inner.set_dirs(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
/// The implementation half of a [`WatchSub`]: whatever has to be told when the
|
||||
/// watched set changes, and whatever has to be torn down on drop.
|
||||
pub trait WatchHandle: Send + Sync {
|
||||
/// Replace the watched directory set.
|
||||
fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A machine's filesystem and git, behind one blocking, object-safe interface.
|
||||
///
|
||||
/// See the module docs for why it blocks and why paths go through
|
||||
/// [`join`](Host::join) / [`is_absolute`](Host::is_absolute) rather than
|
||||
/// `std::path`.
|
||||
pub trait Host: Send + Sync + 'static {
|
||||
// ----- identity --------------------------------------------------------
|
||||
|
||||
/// This host's in-process id.
|
||||
fn id(&self) -> HostId;
|
||||
|
||||
/// The path separator this host's filesystem uses: the platform's own for a
|
||||
/// local host, `/` for a remote Linux or WSL one.
|
||||
fn separator(&self) -> char;
|
||||
|
||||
// ----- path arithmetic -------------------------------------------------
|
||||
|
||||
/// `dir` + `name`, using this host's separator.
|
||||
///
|
||||
/// Use this instead of `Path::join` for any path that might belong to a
|
||||
/// remote host — `PathBuf::join` uses the *client's* separator, which on a
|
||||
/// Windows client talking to Linux produces `/home/me\src`.
|
||||
fn join(&self, dir: &Path, name: &str) -> PathBuf {
|
||||
default_join(dir, name, self.separator())
|
||||
}
|
||||
|
||||
/// Whether `p` is absolute *in this host's semantics*.
|
||||
///
|
||||
/// Use this instead of `Path::is_absolute`: on a Windows client
|
||||
/// `Path::new("/home/me").is_absolute()` is `false` (it reads as
|
||||
/// drive-relative), which would silently mis-classify every remote POSIX
|
||||
/// path.
|
||||
fn is_absolute(&self, p: &Path) -> bool;
|
||||
|
||||
// ----- reading ---------------------------------------------------------
|
||||
|
||||
/// List `dir`, **already sorted**: directories first, then case-insensitive
|
||||
/// by name. Sorting is the host's job so that a remote listing arrives
|
||||
/// ready to render and the order can never drift between hosts.
|
||||
///
|
||||
/// `root` bounds the gitignore chain: each entry's `ignored` is scored by
|
||||
/// walking `.gitignore` files from `root` down to `dir`, deepest match
|
||||
/// winning and `!` whitelists un-ignoring. With `root == None` nothing is
|
||||
/// ignored except `.git` itself.
|
||||
///
|
||||
/// Hidden files are **not** filtered — "show hidden" is a UI preference and
|
||||
/// stays on the client.
|
||||
fn read_dir(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<Entry>>;
|
||||
|
||||
/// Metadata for `p`, symlinks followed.
|
||||
fn stat(&self, p: &Path) -> io::Result<Meta>;
|
||||
|
||||
/// Whether `p` exists. Separate from `stat` so an implementation can answer
|
||||
/// in one round trip instead of shipping metadata nobody asked for.
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
self.stat(p).is_ok()
|
||||
}
|
||||
|
||||
/// Read `p` whole.
|
||||
///
|
||||
/// `max_bytes` is enforced **by the host**: a file over the limit fails with
|
||||
/// [`io::ErrorKind::FileTooLarge`] without its contents being transferred,
|
||||
/// rather than being shipped across an ocean and then discarded.
|
||||
fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result<Vec<u8>>;
|
||||
|
||||
/// Resolve `p` to an absolute path with symlinks and `..` resolved, on the
|
||||
/// host's own filesystem.
|
||||
fn canonicalize(&self, p: &Path) -> io::Result<PathBuf>;
|
||||
|
||||
/// Breadth-first substring search over file names, **executed on the
|
||||
/// host**.
|
||||
///
|
||||
/// Running this client-side would mean up to `max_dirs` separate directory
|
||||
/// listings; at 200ms of round trip each that is a search which takes
|
||||
/// minutes. So the whole walk goes to the host and only the hits come back.
|
||||
///
|
||||
/// The walk starts at each of `roots`, visits at most `max_dirs`
|
||||
/// directories in total, stops at `limit` hits, and — when `show_hidden` is
|
||||
/// false — never descends into an ignored or dot-prefixed directory, which
|
||||
/// is what keeps `node_modules` and `target` from eating the whole budget.
|
||||
fn search(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &str,
|
||||
limit: usize,
|
||||
max_dirs: usize,
|
||||
show_hidden: bool,
|
||||
) -> io::Result<Vec<SearchHit>>;
|
||||
|
||||
// ----- writing ---------------------------------------------------------
|
||||
|
||||
/// Write `bytes` to `p`, creating or truncating it, and answer the file's
|
||||
/// post-write [`Meta`]. A missing parent directory is an error, not
|
||||
/// something to create.
|
||||
///
|
||||
/// **Why it returns `Meta` rather than `()`.** The editor keeps a
|
||||
/// `disk_mtime` baseline to tell its own write apart from someone else's
|
||||
/// edit. Taking that baseline from a *separate* `stat` after the write
|
||||
/// leaves a window: a change landing in between is stamped as ours, and the
|
||||
/// editor then never reports it — silent, and it costs the user their
|
||||
/// conflict prompt. The post-write metadata is the write's own answer, so
|
||||
/// it closes the window by construction. It is also one round trip instead
|
||||
/// of two on every remote save; the control reply already carried it
|
||||
/// (contract §6.4), so nothing on the wire moved.
|
||||
///
|
||||
/// Callers that genuinely don't want it write `.map(|_| ())`.
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta>;
|
||||
|
||||
/// Create `p` as an empty file, failing with
|
||||
/// [`io::ErrorKind::AlreadyExists`] if anything is already there.
|
||||
fn create_file_new(&self, p: &Path) -> io::Result<()>;
|
||||
|
||||
/// Create directory `p`. With `recursive`, create missing parents too and
|
||||
/// treat an existing directory as success.
|
||||
fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()>;
|
||||
|
||||
/// Move `from` to `to`.
|
||||
///
|
||||
/// An existing `to` is [`io::ErrorKind::AlreadyExists`], **guaranteed by
|
||||
/// the implementation** — a caller that probed first would be paying an
|
||||
/// extra round trip for a check that is racy anyway.
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
|
||||
|
||||
/// Remove `p`. `recursive` only means anything for a directory; a
|
||||
/// non-empty directory without it is
|
||||
/// [`io::ErrorKind::DirectoryNotEmpty`].
|
||||
fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>;
|
||||
|
||||
// ----- git -------------------------------------------------------------
|
||||
|
||||
/// The work-tree root `p` belongs to: the nearest ancestor holding a `.git`
|
||||
/// (a directory, or the file a linked worktree gets). `Ok(None)` — not an
|
||||
/// error — when `p` is outside any repository.
|
||||
///
|
||||
/// The whole ancestor walk happens on the host; a remote implementation
|
||||
/// must not climb one level per round trip.
|
||||
fn repo_root(&self, p: &Path) -> io::Result<Option<PathBuf>>;
|
||||
|
||||
/// Run `git -C <cwd> <args>` on the host.
|
||||
///
|
||||
/// `Ok` means git *ran*; its exit code is in [`Output::status`], and a
|
||||
/// non-zero one is a perfectly ordinary answer (`rev-parse` outside a repo
|
||||
/// exits 128). `Err` means it could not be run at all — no git, missing
|
||||
/// `cwd`, connection gone.
|
||||
///
|
||||
/// Every implementation runs it under the same invariants: `-C` rather than
|
||||
/// a current directory, `GIT_OPTIONAL_LOCKS=0`, null stdin, `GIT_DIR` and
|
||||
/// `GIT_WORK_TREE` cleared, and both output streams captured.
|
||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
|
||||
|
||||
// ----- watching --------------------------------------------------------
|
||||
|
||||
/// Open a long-lived, non-recursive watch over `dirs` (which may be empty —
|
||||
/// the set can be filled in later with [`WatchSub::set_dirs`]).
|
||||
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub>;
|
||||
|
||||
// ----- liveness --------------------------------------------------------
|
||||
|
||||
/// Whether the host is reachable right now.
|
||||
///
|
||||
/// Always true locally. A remote host reports false while reconnecting or
|
||||
/// after being taken over, and call sites use that to keep showing the last
|
||||
/// good listing instead of flashing an error.
|
||||
fn is_connected(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Join `name` onto `dir` with an explicit separator — the default
|
||||
/// [`Host::join`], and the one a remote host uses.
|
||||
pub fn default_join(dir: &Path, name: &str, sep: char) -> PathBuf {
|
||||
let mut s = dir.to_string_lossy().into_owned();
|
||||
if !s.is_empty() && !s.ends_with(sep) && !s.ends_with('/') {
|
||||
s.push(sep);
|
||||
}
|
||||
s.push_str(name);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// The alias the rest of the tree uses. A workspace holds one of these; nothing
|
||||
/// holds a concrete host type.
|
||||
pub type SharedHost = Arc<dyn Host>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The hash has to stay bit-for-bit what `daemon::transport` computes, or an
|
||||
/// upgraded client would derive different ids than the daemon it is talking
|
||||
/// to. Pinned against the published FNV-1a-64 vectors rather than against
|
||||
/// our own output, so a "refactor" that changes the algorithm fails here.
|
||||
#[test]
|
||||
fn fnv1a64_matches_the_published_vectors() {
|
||||
assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
|
||||
assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
|
||||
assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8);
|
||||
}
|
||||
|
||||
/// `HostId(0)` means local and nothing derived may claim it. Sweeping a few
|
||||
/// thousand plausible keys is not a proof, but it is the part of the
|
||||
/// reservation that could plausibly regress (someone dropping the `h == 0`
|
||||
/// bump as dead code).
|
||||
#[test]
|
||||
fn zero_is_reserved_for_local() {
|
||||
assert!(HostId::LOCAL.is_local());
|
||||
for i in 0..2000u32 {
|
||||
let key = format!("ssh-direct:me@box{i}:22");
|
||||
assert!(!HostId::from_connection_key(&key).is_local());
|
||||
}
|
||||
assert!(!HostId::from_connection_key("").is_local());
|
||||
}
|
||||
|
||||
/// Same machine, same id; different machines, different ids. This is what
|
||||
/// keeps two workspaces on one remote box sharing a git-status cache.
|
||||
#[test]
|
||||
fn connection_keys_map_to_stable_ids() {
|
||||
let a = HostId::from_connection_key("ssh-direct:me@box:22");
|
||||
assert_eq!(a, HostId::from_connection_key("ssh-direct:me@box:22"));
|
||||
assert_ne!(a, HostId::from_connection_key("ssh-direct:me@box:2222"));
|
||||
assert_ne!(a, HostId::from_connection_key("wsl:Ubuntu"));
|
||||
}
|
||||
|
||||
/// The remote separator wins, whatever the client's `std::path` thinks —
|
||||
/// the whole point of not using `PathBuf::join`.
|
||||
#[test]
|
||||
fn default_join_uses_the_given_separator() {
|
||||
assert_eq!(
|
||||
default_join(Path::new("/home/me"), "src", '/'),
|
||||
PathBuf::from("/home/me/src")
|
||||
);
|
||||
// Already separated: no doubling.
|
||||
assert_eq!(
|
||||
default_join(Path::new("/"), "etc", '/'),
|
||||
PathBuf::from("/etc")
|
||||
);
|
||||
assert_eq!(
|
||||
default_join(Path::new("/home/me/"), "src", '/'),
|
||||
PathBuf::from("/home/me/src")
|
||||
);
|
||||
assert_eq!(
|
||||
default_join(Path::new(r"C:\Users"), "me", '\\'),
|
||||
PathBuf::from(r"C:\Users\me")
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-epoch times round-trip exactly rather than clamping to zero, because
|
||||
/// the editor compares mtimes for equality.
|
||||
#[test]
|
||||
fn mtime_handles_both_sides_of_the_epoch() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let t = UNIX_EPOCH + Duration::new(1_700_000_000, 123_456_789);
|
||||
assert_eq!(
|
||||
MTime::from_system_time(t),
|
||||
MTime {
|
||||
secs: 1_700_000_000,
|
||||
nanos: 123_456_789
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
MTime::from_system_time(UNIX_EPOCH),
|
||||
MTime { secs: 0, nanos: 0 }
|
||||
);
|
||||
let before = UNIX_EPOCH - Duration::new(1, 500_000_000);
|
||||
assert_eq!(
|
||||
MTime::from_system_time(before),
|
||||
MTime {
|
||||
secs: -2,
|
||||
nanos: 500_000_000
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// `Err` is "it did not run"; a non-zero exit is an ordinary `Ok`. Every
|
||||
/// git call site's error handling is built on that split.
|
||||
#[test]
|
||||
fn output_success_is_exit_zero_only() {
|
||||
let ok = Output {
|
||||
status: Some(0),
|
||||
stdout: b" main\n".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
assert!(ok.success());
|
||||
assert_eq!(ok.stdout_trimmed(), "main");
|
||||
let bad = Output {
|
||||
status: Some(128),
|
||||
stdout: Vec::new(),
|
||||
stderr: b"not a git repository\n".to_vec(),
|
||||
};
|
||||
assert!(!bad.success());
|
||||
assert_eq!(bad.stderr_trimmed(), "not a git repository");
|
||||
let signalled = Output {
|
||||
status: None,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
assert!(!signalled.success());
|
||||
}
|
||||
|
||||
/// Non-UTF-8 output does not lose the run: it comes back lossy rather than
|
||||
/// turning the call into an error.
|
||||
#[test]
|
||||
fn output_text_is_lossy_not_fallible() {
|
||||
let o = Output {
|
||||
status: Some(0),
|
||||
stdout: vec![0xff, b'a', 0xfe],
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
assert!(o.stdout_trimmed().contains('a'));
|
||||
}
|
||||
|
||||
/// Arbitrary bytes survive a JSON round trip, and they do it as base64
|
||||
/// rather than as an array of numbers — the difference between 1.33× and 4×
|
||||
/// on a `git diff` big enough to matter.
|
||||
#[test]
|
||||
fn output_bytes_cross_json_as_base64() {
|
||||
let o = Output {
|
||||
status: Some(1),
|
||||
stdout: vec![0x00, 0xff, 0x80, b'h', b'i'],
|
||||
stderr: b"boom".to_vec(),
|
||||
};
|
||||
let json = serde_json::to_string(&o).unwrap();
|
||||
assert!(json.contains("\"stdout\":\"AP+AaGk=\""), "{json}");
|
||||
assert!(
|
||||
!json.contains('['),
|
||||
"bytes must not render as an array: {json}"
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<Output>(&json).unwrap(), o);
|
||||
}
|
||||
|
||||
/// The listing/metadata types have to survive the same trip, since they are
|
||||
/// the payload of every read RPC.
|
||||
#[test]
|
||||
fn value_types_round_trip_through_json() {
|
||||
let e = Entry {
|
||||
name: "src".into(),
|
||||
is_dir: true,
|
||||
is_symlink: false,
|
||||
ignored: false,
|
||||
};
|
||||
let back: Entry = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
|
||||
assert_eq!(back, e);
|
||||
|
||||
let m = Meta {
|
||||
is_dir: false,
|
||||
is_symlink: true,
|
||||
len: 42,
|
||||
mtime: Some(MTime {
|
||||
secs: -1,
|
||||
nanos: 999_999_999,
|
||||
}),
|
||||
readonly: true,
|
||||
};
|
||||
let back: Meta = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
|
||||
assert_eq!(back, m);
|
||||
|
||||
let h = SearchHit {
|
||||
name: "a.rs".into(),
|
||||
path: PathBuf::from("/tmp/a.rs"),
|
||||
is_dir: false,
|
||||
ignored: true,
|
||||
};
|
||||
let back: SearchHit = serde_json::from_str(&serde_json::to_string(&h).unwrap()).unwrap();
|
||||
assert_eq!(back, h);
|
||||
}
|
||||
|
||||
/// The guard is inert until a UI thread claims itself, which is what lets
|
||||
/// `tty7-server` — and every test — call hosts from any thread.
|
||||
#[test]
|
||||
fn the_ui_guard_is_inert_without_registration() {
|
||||
// No `register_ui_thread` in the server or in tests, so this is a no-op
|
||||
// rather than a panic.
|
||||
guard_off_ui();
|
||||
}
|
||||
|
||||
/// Object safety is not decoration: the whole tree stores `Arc<dyn Host>`,
|
||||
/// and the conformance suite takes `&dyn Host` precisely to keep this true.
|
||||
#[test]
|
||||
fn host_is_object_safe() {
|
||||
fn takes_dyn(_h: &dyn Host) {}
|
||||
let h: SharedHost = local::LocalHost::new();
|
||||
takes_dyn(&*h);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
//! tty7's framework-free core.
|
||||
//!
|
||||
//! Everything that has to run on a machine with no display lives here: the
|
||||
//! wire protocol, the session daemon (PTY ownership, replay rings, fan-out),
|
||||
//! the native SSH engine, and the parts of the domain model — config, session
|
||||
//! layout, shell/agent knowledge, git — that the GUI and the headless
|
||||
//! `tty7-server` must agree on byte for byte.
|
||||
//!
|
||||
//! **This crate must never depend on gpui.** That is the invariant the split
|
||||
//! exists to enforce (see `docs/2026-07-27-remote-workspace-design.md` §11);
|
||||
//! `cargo tree -p tty7-core | grep gpui` must stay empty. Where a type genuinely
|
||||
//! needs a gpui shape — `Config` as a `Global`, `WindowState` as a `Bounds`,
|
||||
//! `FontFeatures` — the data lives here and the GUI crate adds the gpui-facing
|
||||
//! layer on top.
|
||||
//!
|
||||
//! The module paths deliberately mirror what they were inside the old single
|
||||
//! crate (`crate::core::config`, `crate::daemon::protocol`), and the GUI crate
|
||||
//! re-exports them under the same names, so call sites read identically on
|
||||
//! either side of the boundary.
|
||||
|
||||
pub mod core;
|
||||
pub mod daemon;
|
||||
pub mod host;
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "tty7-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "tty7's headless session server: the persistent terminal daemon, with no GUI attached"
|
||||
repository = "https://github.com/l0ng-ai/tty7"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "tty7-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
# One dependency, on purpose. This binary exists to prove — and keep proving —
|
||||
# that everything a tty7 session needs runs without gpui: if it ever grows a
|
||||
# second dependency that the GUI also needs, that dependency belongs in
|
||||
# `tty7-core` instead.
|
||||
[dependencies]
|
||||
tty7-core = { path = "../tty7-core" }
|
||||
|
||||
# The end-to-end proof (`tests/stdio_conformance.rs`) runs `tty7-core`'s shared
|
||||
# `Host` conformance suite against a *real* `tty7-server --stdio` child process.
|
||||
# It has to live in this crate because that is the crate that owns the binary —
|
||||
# `CARGO_BIN_EXE_tty7-server` only exists for its own tests.
|
||||
[dev-dependencies]
|
||||
# Sandboxes for the suite: an empty directory per case, removed on drop. The
|
||||
# server is on this machine, so a local temp dir is a path in its namespace.
|
||||
tempfile = "3"
|
||||
# Workspace records cross the control wire as opaque JSON (contract §6.4), so
|
||||
# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary
|
||||
# itself still depends on nothing but `tty7-core`.
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,387 @@
|
||||
//! `tty7-server` — the tty7 session daemon with no GUI attached.
|
||||
//!
|
||||
//! This is the binary that runs on the machine a *remote* workspace lives on
|
||||
//! (see `docs/2026-07-27-remote-workspace-design.md`). It runs the same
|
||||
//! `daemon::server` the local GUI auto-spawns, plus the control listener that
|
||||
//! backs a remote `Host`; the only difference from the GUI's daemon is that
|
||||
//! nothing on this side ever opens a window, which is why the code it needs had
|
||||
//! to leave the GUI crate first.
|
||||
//!
|
||||
//! | Command | Effect |
|
||||
//! |---|---|
|
||||
//! | `--daemon` | Serve panes *and* control connections in the foreground until killed |
|
||||
//! | `--stdio` | Carry one control connection on this process's stdin/stdout |
|
||||
//! | `agent-hook <agent> <event>` | Emit one agent sentinel event; the same code the GUI binary runs |
|
||||
//!
|
||||
//! # The two sockets
|
||||
//!
|
||||
//! `--daemon` listens twice, on purpose:
|
||||
//!
|
||||
//! | Dialect | Endpoint | Served by |
|
||||
//! |---|---|---|
|
||||
//! | Panes (`daemon::protocol`) | `<config-dir>/daemon.sock` | `daemon::server::run` |
|
||||
//! | Control (`daemon::control`) | `$XDG_RUNTIME_DIR/tty7/daemon.sock` | `host::server` |
|
||||
//!
|
||||
//! They are separate because the roles are separate. A machine can back a remote
|
||||
//! workspace's file tree without hosting a single pane, and a pane daemon that
|
||||
//! predates the control dialect must keep working untouched. Folding control
|
||||
//! into the pane listener would have made every existing client's version
|
||||
//! negotiation answer for a feature it does not use.
|
||||
//!
|
||||
//! # `--stdio`
|
||||
//!
|
||||
//! Two jobs behind one flag, chosen by whether a control server is already
|
||||
//! listening on this machine:
|
||||
//!
|
||||
//! | Situation | Mode | Why |
|
||||
//! |---|---|---|
|
||||
//! | A `--daemon` is up | **bridge** — copy bytes between stdio and its socket | One server per machine owns the state; a second one would fork it |
|
||||
//! | Nothing is listening | **serve** — answer control requests here | A box with no daemon still has to be reachable |
|
||||
//!
|
||||
//! `--bridge` and `--serve` force one or the other. The auto choice is what
|
||||
//! makes `ssh host tty7-server --stdio` work whether or not the remote already
|
||||
//! had a daemon, which is the fallback path for
|
||||
//! `AllowStreamLocalForwarding no`, the only path under WSL, and how the
|
||||
//! end-to-end conformance test reaches a real server without an sshd.
|
||||
|
||||
use std::io;
|
||||
use std::process::ExitCode;
|
||||
|
||||
const USAGE: &str = "\
|
||||
tty7-server — the tty7 session daemon, headless
|
||||
|
||||
USAGE:
|
||||
tty7-server --daemon [--config-dir <dir>]
|
||||
tty7-server --stdio [--serve | --bridge] [--control-sock <path>]
|
||||
tty7-server --stdio --pane [--config-dir <dir>]
|
||||
tty7-server agent-hook <agent> <event>
|
||||
|
||||
OPTIONS:
|
||||
--daemon Serve panes and control connections until killed
|
||||
--stdio Carry one control connection on stdin/stdout
|
||||
--serve Answer requests in this process (no socket)
|
||||
--bridge Forward to the machine's control socket
|
||||
--pane Forward to the machine's *pane* socket instead
|
||||
--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
|
||||
-V, --version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// The agent-hook emitter runs before anything else touches config, logging
|
||||
// or the crash handler: it is a fire-and-forget child of an agent's hook
|
||||
// runner that must stay silent and exit fast, and the same code the GUI
|
||||
// binary runs for `tty7 agent-hook`. Only the binary that carries it
|
||||
// changed — a remote machine has a `tty7-server` and no `tty7`.
|
||||
if args.first().map(String::as_str) == Some("agent-hook") {
|
||||
if let (Some(agent), Some(event)) = (args.get(1), args.get(2)) {
|
||||
tty7_core::core::agent_hooks::run_agent_hook(agent, event);
|
||||
}
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("tty7-server {}", env!("CARGO_PKG_VERSION"));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
if args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
print!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// Resolve the config-dir override before anything touches config, session,
|
||||
// or the socket path — they all resolve under it, so the order matters.
|
||||
// Same parsing as the GUI's `apply_config_dir_arg`.
|
||||
apply_config_dir_arg(&args);
|
||||
|
||||
// Panics in the server are recorded to `crash.log` in the config dir, for
|
||||
// the same reason the GUI does it: on a headless box there is no console to
|
||||
// read a backtrace off, and the process that notices the crash is a client
|
||||
// on the other end of a socket.
|
||||
tty7_core::core::crash::install("server");
|
||||
// Same reasoning for the ordinary log records: a headless box has no
|
||||
// console, and the client that notices a problem is on the far end of a
|
||||
// socket. Off unless `TTY7_LOG` asks for it.
|
||||
tty7_core::core::logfile::install("server");
|
||||
|
||||
if args.iter().any(|a| a == "--stdio") {
|
||||
return match run_stdio(&args) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
// stderr, never stdout: stdout is the protocol.
|
||||
eprintln!("tty7-server: stdio session ended with error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--daemon") {
|
||||
return run_daemon();
|
||||
}
|
||||
|
||||
eprint!("tty7-server: nothing to do without --daemon or --stdio\n\n{USAGE}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// Serve panes and control connections until killed.
|
||||
fn run_daemon() -> ExitCode {
|
||||
// Control first, and on its own thread: a machine that cannot host panes
|
||||
// (no pty, a locked-down container) should still be able to back a remote
|
||||
// workspace's files, so a control failure is reported and stepped over
|
||||
// rather than being fatal.
|
||||
#[cfg(unix)]
|
||||
match tty7_core::host::server::spawn_control_listener_with(
|
||||
tty7_core::host::local::LocalHost::shared(),
|
||||
control_services(),
|
||||
) {
|
||||
Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()),
|
||||
Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"),
|
||||
}
|
||||
|
||||
if let Err(e) = tty7_core::daemon::server::run() {
|
||||
eprintln!("tty7-server: daemon exited with error: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Carry one control connection on this process's stdin/stdout.
|
||||
fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = args;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"--stdio is a Unix path; a Windows server is reached over its own transport",
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::net::UnixStream;
|
||||
use tty7_core::daemon::duplex::StdioDuplex;
|
||||
use tty7_core::host::local::LocalHost;
|
||||
use tty7_core::host::server;
|
||||
|
||||
let force_serve = args.iter().any(|a| a == "--serve");
|
||||
let force_bridge = args.iter().any(|a| a == "--bridge");
|
||||
if force_serve && force_bridge {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--serve and --bridge ask for opposite things",
|
||||
));
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--pane") {
|
||||
if force_serve {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--pane is a bridge; there is nothing for --serve to answer in this process",
|
||||
));
|
||||
}
|
||||
return bridge_panes();
|
||||
}
|
||||
|
||||
let sock = match flag_value(args, "--control-sock") {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => server::control_socket_path()?,
|
||||
};
|
||||
|
||||
// Probe unless told which mode to use. Connecting is the only way to
|
||||
// tell a live server from a socket file a crash left behind, and it is
|
||||
// also exactly the connection the bridge would have made anyway.
|
||||
let upstream = if force_serve {
|
||||
None
|
||||
} else {
|
||||
match UnixStream::connect(&sock) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) if force_bridge => return Err(e),
|
||||
Err(e) => {
|
||||
log_stderr(format_args!(
|
||||
"no control server at {} ({e}); serving in this process",
|
||||
sock.display()
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match upstream {
|
||||
Some(s) => bridge(s),
|
||||
None => {
|
||||
// Takes stdin/stdout away from the rest of the process before a
|
||||
// single frame is written — see `StdioDuplex::take`.
|
||||
let link = StdioDuplex::take()?;
|
||||
server::serve_with(link, LocalHost::shared(), control_services())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry one **pane** connection on stdin/stdout, bridged to this machine's pane
|
||||
/// socket.
|
||||
///
|
||||
/// # Why panes need their own stdio mode
|
||||
///
|
||||
/// `--daemon` listens twice (see this module's header), and a routed connection
|
||||
/// is for exactly one of the two dialects. The control half already had a way in
|
||||
/// — plain `--stdio`. The pane half had none, which is why a remote workspace
|
||||
/// could browse a file tree and could not open a single terminal.
|
||||
///
|
||||
/// # Why it always bridges and never serves
|
||||
///
|
||||
/// Panes are *state*. Serving them in this process would give every routed
|
||||
/// connection its own registry, so a pane would die with the window that opened
|
||||
/// it and `List` would never see anything anyone else spawned — the exact
|
||||
/// failure `install::wsl::ensure_wsl_server`'s doc warns about, one layer down.
|
||||
/// There is one pane daemon per machine and this connects to it, starting it
|
||||
/// first if nobody has.
|
||||
#[cfg(unix)]
|
||||
fn bridge_panes() -> io::Result<()> {
|
||||
use tty7_core::daemon::{spawn, transport};
|
||||
|
||||
let upstream = match transport::connect() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// `ensure_running` re-execs *this* binary with `--daemon`, which is
|
||||
// what starts both listeners. Normally the install path has already
|
||||
// done it and this never runs; it covers the daemon dying between
|
||||
// that check and this connection.
|
||||
log_stderr(format_args!(
|
||||
"no pane daemon at {} ({e}); starting one",
|
||||
transport::endpoint_display()
|
||||
));
|
||||
spawn::ensure_running().map_err(io::Error::other)?;
|
||||
transport::connect()?
|
||||
}
|
||||
};
|
||||
bridge(upstream)
|
||||
}
|
||||
|
||||
/// Copy bytes between this process's stdio and an already-running control
|
||||
/// server, in both directions, until either side stops.
|
||||
///
|
||||
/// Deliberately dumb: it parses nothing. The version handshake this stream
|
||||
/// carries is between the *client* and the server at the far end (contract
|
||||
/// §6.9), and a bridge that understood the frames would be a third opinion about
|
||||
/// the protocol version, which is exactly the coupling the design forbids.
|
||||
#[cfg(unix)]
|
||||
fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::net::Shutdown;
|
||||
|
||||
let mut up_read = upstream.try_clone()?;
|
||||
let mut up_write = upstream.try_clone()?;
|
||||
|
||||
// Upstream → stdout on this thread, stdin → upstream on another. Either
|
||||
// direction ending means the session is over, so whichever finishes first
|
||||
// shuts the socket down and the other returns immediately instead of
|
||||
// parking on a peer that will never speak again.
|
||||
//
|
||||
// **The feeder is never joined.** Shutting the socket down wakes a thread
|
||||
// blocked on *the socket*, but this one is blocked on `stdin`, and nothing
|
||||
// this process can do wakes that — the far end of the pipe is `ssh`, or a
|
||||
// parent that has no reason to close it. Joining it turns "the server hung
|
||||
// up" into a bridge that never exits and, worse, never closes its stdout, so
|
||||
// the client at the far end waits forever for an EOF that is sitting in this
|
||||
// process. Returning lets the process exit, which closes stdout, which is
|
||||
// the signal the client is actually waiting for. Design §10's takeover is
|
||||
// the case that made this visible: the server closes the displaced session's
|
||||
// link, and that has to reach the client through this bridge.
|
||||
let feeder_socket = upstream.try_clone()?;
|
||||
let feeder = std::thread::Builder::new()
|
||||
.name("tty7-stdio-bridge-in".into())
|
||||
.spawn(move || {
|
||||
let mut stdin = io::stdin().lock();
|
||||
let _ = io::copy(&mut stdin, &mut up_write);
|
||||
let _ = feeder_socket.shutdown(Shutdown::Both);
|
||||
})?;
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
match up_read.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
stdout.write_all(&buf[..n])?;
|
||||
// Flushed per read, not per buffer: a control reply is useless
|
||||
// sitting in a buffer waiting for the next one, and the peer is
|
||||
// blocked on it.
|
||||
stdout.flush()?;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => {
|
||||
let _ = upstream.shutdown(Shutdown::Both);
|
||||
drop(feeder);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = upstream.shutdown(Shutdown::Both);
|
||||
drop(feeder);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this machine offers over a control connection, beyond its filesystem.
|
||||
///
|
||||
/// The workspace store is the reason this binary exists on a remote box at all:
|
||||
/// design §10 puts the workspace list, the tab/pane tree and each pane's cwd on
|
||||
/// **the machine the panes run on**, so that connecting from a different laptop
|
||||
/// shows the same thing. The client's `session.json` keeps only its own view
|
||||
/// state.
|
||||
///
|
||||
/// A machine with no home directory to place the file in still serves files and
|
||||
/// panes — it simply says `workspace-store` is not among its capabilities, and
|
||||
/// clients see the same "does not serve the workspace store" answer a
|
||||
/// pre-M5 server gives.
|
||||
fn control_services() -> tty7_core::host::server::Services {
|
||||
use tty7_core::core::workspace_store::WorkspaceStore;
|
||||
match WorkspaceStore::shared() {
|
||||
Ok(store) => {
|
||||
log_stderr(format_args!(
|
||||
"workspace store at {}",
|
||||
store.path().display()
|
||||
));
|
||||
tty7_core::host::server::Services::with_workspaces(store)
|
||||
}
|
||||
Err(e) => {
|
||||
log_stderr(format_args!(
|
||||
"no workspace store ({e}); serving files and panes only"
|
||||
));
|
||||
tty7_core::host::server::Services::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--flag <value>` or `--flag=<value>`, first occurrence wins.
|
||||
fn flag_value(args: &[String], flag: &str) -> Option<String> {
|
||||
let with_eq = format!("{flag}=");
|
||||
let mut it = args.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if let Some(v) = arg.strip_prefix(&with_eq) {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
if arg == flag {
|
||||
return it.next().cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// stderr only. In `--stdio` mode stdout belongs to the protocol, and the
|
||||
/// `log` crate has no sink configured in this binary.
|
||||
fn log_stderr(args: std::fmt::Arguments<'_>) {
|
||||
eprintln!("tty7-server: {args}");
|
||||
}
|
||||
|
||||
/// Honour `--config-dir <dir>` / `--config-dir=<dir>`, first occurrence wins —
|
||||
/// the same contract (and the same first-call-wins `set_config_dir`) as the GUI.
|
||||
fn apply_config_dir_arg(args: &[String]) {
|
||||
if let Some(path) = flag_value(args, "--config-dir") {
|
||||
tty7_core::core::config::set_config_dir(path.into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! The `tty7-server` command line: the three subcommands, and the two shapes
|
||||
//! `--stdio` takes.
|
||||
//!
|
||||
//! `stdio_conformance.rs` proves the *protocol* over `--stdio --serve`. This
|
||||
//! file proves the argument handling and the byte bridge — the mode that carries
|
||||
//! a connection to a control server that is already running, which is the path
|
||||
//! an `ssh host tty7-server --stdio` takes on a machine with a live daemon and
|
||||
//! which no amount of `Host` conformance would exercise.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tty7_core::daemon::control::{ControlHello, LinkShutdown};
|
||||
use tty7_core::host::Host;
|
||||
use tty7_core::host::local::LocalHost;
|
||||
use tty7_core::host::remote::RemoteHost;
|
||||
use tty7_core::host::server;
|
||||
|
||||
const EXE: &str = env!("CARGO_BIN_EXE_tty7-server");
|
||||
|
||||
struct ServerProcess(Mutex<Option<Child>>);
|
||||
|
||||
impl LinkShutdown for ServerProcess {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
if let Some(mut c) = self.0.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Start `tty7-server --stdio <args>` and connect a `RemoteHost` to its pipes.
|
||||
fn stdio_child(args: &[&str]) -> io::Result<Arc<RemoteHost>> {
|
||||
let mut child = Command::new(EXE)
|
||||
.arg("--stdio")
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
let out = child.stdout.take().expect("piped");
|
||||
let inp = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess(Mutex::new(Some(child))));
|
||||
let hello = ControlHello::host_rpc("cli-test", "localhost");
|
||||
RemoteHost::connect_with(out, inp, Some(closer), "stdio:cli", &hello)
|
||||
}
|
||||
|
||||
/// A control server on a temp socket, for the bridge to reach.
|
||||
fn listening_server(dir: &tempfile::TempDir) -> PathBuf {
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
std::thread::spawn(move || server::serve_listener(listener, LocalHost::new()));
|
||||
sock
|
||||
}
|
||||
|
||||
/// **The bridge.** `--stdio --bridge` forwards bytes between its own pipes and a
|
||||
/// control server that is already listening, parsing nothing on the way.
|
||||
///
|
||||
/// That "parsing nothing" is the load-bearing part: the version handshake this
|
||||
/// stream carries belongs to the client and the server at the far end, and a
|
||||
/// bridge with an opinion about the protocol would become a third party to a
|
||||
/// negotiation it is not qualified to join.
|
||||
#[test]
|
||||
fn the_bridge_carries_a_whole_session() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let sock = listening_server(&dir);
|
||||
|
||||
let host = stdio_child(&["--bridge", "--control-sock", &sock.to_string_lossy()])
|
||||
.expect("bridge handshake");
|
||||
|
||||
let sandbox = tempfile::TempDir::new().unwrap();
|
||||
let f = host.join(sandbox.path(), "through-the-bridge.txt");
|
||||
host.write_file(&f, b"two hops").unwrap();
|
||||
assert_eq!(host.read_file(&f, 1024).unwrap(), b"two hops");
|
||||
|
||||
// A payload big enough that it cannot arrive in one read, so the bridge's
|
||||
// copy loop is doing real work rather than passing a single buffer through.
|
||||
let big = host.join(sandbox.path(), "big.bin");
|
||||
let body: Vec<u8> = (0..2 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
|
||||
host.write_file(&big, &body).unwrap();
|
||||
assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body);
|
||||
|
||||
// Out-of-order replies survive the extra hop too: the bridge must not
|
||||
// serialize what the server took care to keep concurrent.
|
||||
let entries = host.read_dir(sandbox.path(), None).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
/// `--bridge` with nowhere to bridge to fails rather than quietly serving
|
||||
/// itself. An operator who asked for the bridge is telling us a server exists;
|
||||
/// silently becoming that server would fork the machine's state in two.
|
||||
#[test]
|
||||
fn an_explicit_bridge_with_no_server_fails() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let missing = dir.path().join("nobody-here.sock");
|
||||
let err = stdio_child(&["--bridge", "--control-sock", &missing.to_string_lossy()]);
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"--bridge should not fall back to serving in-process"
|
||||
);
|
||||
}
|
||||
|
||||
/// With neither flag, `--stdio` probes: nothing listening means serve here, so a
|
||||
/// machine that has never run a daemon is still reachable over ssh.
|
||||
#[test]
|
||||
fn the_default_mode_serves_when_nothing_is_listening() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let missing = dir.path().join("nobody-here.sock");
|
||||
let host = stdio_child(&["--control-sock", &missing.to_string_lossy()])
|
||||
.expect("the probe should fall through to serving");
|
||||
let sandbox = tempfile::TempDir::new().unwrap();
|
||||
assert!(host.exists(sandbox.path()));
|
||||
}
|
||||
|
||||
/// ...and something listening means bridge to it, so a second `--stdio` session
|
||||
/// joins the machine's existing server instead of standing up a rival.
|
||||
#[test]
|
||||
fn the_default_mode_bridges_when_a_server_is_listening() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let sock = listening_server(&dir);
|
||||
let host =
|
||||
stdio_child(&["--control-sock", &sock.to_string_lossy()]).expect("the probe should bridge");
|
||||
let sandbox = tempfile::TempDir::new().unwrap();
|
||||
let f = host.join(sandbox.path(), "auto.txt");
|
||||
host.write_file(&f, b"ok").unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"ok");
|
||||
}
|
||||
|
||||
/// Contradictory flags are refused rather than one silently winning.
|
||||
#[test]
|
||||
fn serve_and_bridge_together_are_refused() {
|
||||
let out = Command::new(EXE)
|
||||
.args(["--stdio", "--serve", "--bridge"])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("opposite"),
|
||||
"{:?}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// `agent-hook` runs the same emitter the GUI binary does, and stays quiet.
|
||||
///
|
||||
/// Quiet is the requirement, not a nicety: this runs as a child of an agent's
|
||||
/// hook runner, and anything it prints lands in the agent's own transcript. With
|
||||
/// no controlling terminal there is nowhere to emit to, and it still has to
|
||||
/// succeed — a hook that fails is a hook the agent reports as broken.
|
||||
#[test]
|
||||
fn agent_hook_is_quiet_and_succeeds() {
|
||||
let out = Command::new(EXE)
|
||||
.args(["agent-hook", "claude", "Stop"])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "agent-hook exited {:?}", out.status);
|
||||
assert!(out.stdout.is_empty(), "agent-hook wrote to stdout");
|
||||
}
|
||||
|
||||
/// A malformed `agent-hook` invocation is still silent and still succeeds — the
|
||||
/// emitter's whole contract is that it never becomes the agent's problem.
|
||||
#[test]
|
||||
fn agent_hook_without_arguments_still_succeeds() {
|
||||
let out = Command::new(EXE)
|
||||
.arg("agent-hook")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
assert!(out.stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_and_help_report_on_stdout() {
|
||||
let v = Command::new(EXE).arg("--version").output().unwrap();
|
||||
assert!(v.status.success());
|
||||
assert!(String::from_utf8_lossy(&v.stdout).starts_with("tty7-server "));
|
||||
|
||||
let h = Command::new(EXE).arg("--help").output().unwrap();
|
||||
assert!(h.status.success());
|
||||
let text = String::from_utf8_lossy(&h.stdout);
|
||||
for expected in ["--daemon", "--stdio", "agent-hook", "--control-sock"] {
|
||||
assert!(text.contains(expected), "help omits {expected}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// No arguments is a usage error, not a process that sits there doing nothing.
|
||||
#[test]
|
||||
fn no_arguments_is_a_usage_error() {
|
||||
let out = Command::new(EXE).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--daemon"));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The local daemon's [`RemoteRouter`] in front of a real `tty7-server`, and
|
||||
//! the remote socket path the two sides have to agree on.
|
||||
//!
|
||||
//! `stdio_conformance.rs` proves the protocol over `--stdio`; `cli.rs` proves
|
||||
//! the server's own byte bridge. This file proves the hop *before* both of
|
||||
//! them — the one where a GUI's local connection is handed to a machine that is
|
||||
//! not this one — over the `--stdio` fallback, which is the transport a host
|
||||
//! with `AllowStreamLocalForwarding no` gets and the only one that can be
|
||||
//! exercised without an sshd.
|
||||
//!
|
||||
//! The `direct-streamlocal` half deliberately has no test here: it needs a
|
||||
//! running sshd with the option flipped both ways. What *is* testable is the
|
||||
//! decision between them, which lives in `remote_link::choose_entry` and is
|
||||
//! unit-tested there.
|
||||
|
||||
// Unix-only: the hub this stands up is a Unix-domain socket, which is also the
|
||||
// only shape the remote side of a routed connection takes (contract §8). The
|
||||
// Windows client reaches a *remote* server the same way; it is the local hop
|
||||
// that differs, and `daemon::router` covers that with its own `cfg`.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use tty7_core::daemon::control::ControlHello;
|
||||
use tty7_core::daemon::remote_link::{RemoteEnv, remote_control_socket};
|
||||
use tty7_core::daemon::router::{RemoteRouter, RouteAck, RouteHeader};
|
||||
use tty7_core::host::Host;
|
||||
use tty7_core::host::remote::RemoteHost;
|
||||
|
||||
const EXE: &str = env!("CARGO_BIN_EXE_tty7-server");
|
||||
|
||||
/// **The fallback path, end to end.** A client connects to a local socket,
|
||||
/// names a target, and gets a `Host` backed by a `tty7-server` process it never
|
||||
/// spoke to directly — every byte of the control dialect crossing a router that
|
||||
/// does not know what a control frame is.
|
||||
#[test]
|
||||
fn a_routed_connection_reaches_a_real_server() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let hub = dir.path().join("hub.sock");
|
||||
let listener = UnixListener::bind(&hub).unwrap();
|
||||
|
||||
// The local daemon's side: accept, read the route header, forward forever.
|
||||
let router = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = stream.try_clone().unwrap();
|
||||
let (kind, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap();
|
||||
assert_eq!(kind, tty7_core::daemon::router::ROUTE_KIND);
|
||||
let header = RouteHeader::decode(&payload).unwrap();
|
||||
RemoteRouter::route(stream, &header)
|
||||
});
|
||||
|
||||
// The client's side: one extra frame in front of an otherwise ordinary
|
||||
// control connection.
|
||||
let mut sock = UnixStream::connect(&hub).unwrap();
|
||||
let missing = dir.path().join("nobody-here.sock");
|
||||
let header = RouteHeader::local_stdio(
|
||||
EXE,
|
||||
&[
|
||||
"--stdio",
|
||||
"--serve",
|
||||
"--control-sock",
|
||||
&missing.to_string_lossy(),
|
||||
],
|
||||
);
|
||||
header.write(&mut sock).unwrap();
|
||||
|
||||
let ack = RouteAck::read(&mut sock).expect("the route should be accepted");
|
||||
assert_eq!(ack.link.as_deref(), Some("local-stdio"));
|
||||
|
||||
let hello = ControlHello::host_rpc("router-test", "localhost");
|
||||
let host = RemoteHost::over_unix(sock, "routed:local-stdio", &hello)
|
||||
.expect("handshake through the router");
|
||||
|
||||
// The handshake itself already crossed the router in both directions; these
|
||||
// prove it keeps working for payloads that span many reads, which is where
|
||||
// a router that buffered or reframed would come apart.
|
||||
let sandbox = tempfile::TempDir::new().unwrap();
|
||||
let file = host.join(sandbox.path(), "through-the-router.txt");
|
||||
host.write_file(&file, b"two hops and a pipe").unwrap();
|
||||
assert_eq!(host.read_file(&file, 1024).unwrap(), b"two hops and a pipe");
|
||||
|
||||
let big = host.join(sandbox.path(), "big.bin");
|
||||
let body: Vec<u8> = (0..2 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
|
||||
host.write_file(&big, &body).unwrap();
|
||||
assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body);
|
||||
|
||||
// Out-of-order replies survive the hop: the router must not serialize what
|
||||
// the server took care to keep concurrent.
|
||||
assert_eq!(host.read_dir(sandbox.path(), None).unwrap().len(), 2);
|
||||
|
||||
drop(host);
|
||||
let _ = router.join().unwrap();
|
||||
}
|
||||
|
||||
/// A route to a target that cannot be opened comes back as a *reason*.
|
||||
///
|
||||
/// Without the ack the client would see a socket that closed with no
|
||||
/// explanation, which for a remote workspace is the difference between "the
|
||||
/// binary isn't installed on that box" and a bug report saying "it doesn't
|
||||
/// work".
|
||||
#[test]
|
||||
fn an_unreachable_target_is_reported_not_dropped() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let hub = dir.path().join("hub.sock");
|
||||
let listener = UnixListener::bind(&hub).unwrap();
|
||||
|
||||
let router = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = stream.try_clone().unwrap();
|
||||
let (_, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap();
|
||||
let header = RouteHeader::decode(&payload).unwrap();
|
||||
RemoteRouter::route(stream, &header)
|
||||
});
|
||||
|
||||
let mut sock = UnixStream::connect(&hub).unwrap();
|
||||
RouteHeader::local_stdio("tty7-server-that-does-not-exist", &[])
|
||||
.write(&mut sock)
|
||||
.unwrap();
|
||||
|
||||
let err = RouteAck::read(&mut sock).expect_err("there is nothing to route to");
|
||||
assert!(
|
||||
!err.to_string().is_empty(),
|
||||
"the failure must say something"
|
||||
);
|
||||
assert!(router.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
/// **The two sides derive the same path.** `remote_link::remote_control_socket`
|
||||
/// computes, from a remote's environment, the socket a `direct-streamlocal`
|
||||
/// channel is pointed at; `host::server::control_socket_path` computes, in the
|
||||
/// server process, the socket it binds. Nothing reconciles them at run time —
|
||||
/// a mismatch is a connection that fails with `connect failed` and no hint
|
||||
/// which side is wrong — so the agreement is checked against the real binary
|
||||
/// rather than asserted in prose.
|
||||
#[test]
|
||||
fn the_derived_remote_socket_is_the_one_the_server_binds() {
|
||||
// Both orders the server resolves: `$XDG_RUNTIME_DIR` when it has one, and
|
||||
// `$HOME/.local/share` when it does not (macOS, minimal containers).
|
||||
let with_runtime = tempfile::TempDir::new().unwrap();
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
let runtime_path = with_runtime.path().to_string_lossy().to_string();
|
||||
let home_path = home.path().to_string_lossy().to_string();
|
||||
|
||||
let bound = bound_control_socket(Some(&runtime_path), &home_path);
|
||||
let derived = remote_control_socket(&RemoteEnv {
|
||||
control_sock: None,
|
||||
xdg_runtime_dir: Some(runtime_path.clone()),
|
||||
home: Some(home_path.clone()),
|
||||
tmpdir: std::env::var("TMPDIR").ok(),
|
||||
});
|
||||
assert_eq!(derived.as_deref(), Some(bound.as_str()));
|
||||
|
||||
let bound = bound_control_socket(None, &home_path);
|
||||
let derived = remote_control_socket(&RemoteEnv {
|
||||
control_sock: None,
|
||||
xdg_runtime_dir: None,
|
||||
home: Some(home_path.clone()),
|
||||
tmpdir: std::env::var("TMPDIR").ok(),
|
||||
});
|
||||
assert_eq!(derived.as_deref(), Some(bound.as_str()));
|
||||
}
|
||||
|
||||
/// Start `tty7-server --daemon` under a controlled environment and read back
|
||||
/// the control socket it actually bound (it prints it on stderr), then stop it.
|
||||
fn bound_control_socket(runtime_dir: Option<&str>, home: &str) -> String {
|
||||
let config = tempfile::TempDir::new().unwrap();
|
||||
let mut cmd = Command::new(EXE);
|
||||
cmd.arg("--daemon")
|
||||
.arg("--config-dir")
|
||||
.arg(config.path())
|
||||
.env("HOME", home)
|
||||
.env_remove("TTY7_CONTROL_SOCK")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
match runtime_dir {
|
||||
Some(dir) => cmd.env("XDG_RUNTIME_DIR", dir),
|
||||
None => cmd.env_remove("XDG_RUNTIME_DIR"),
|
||||
};
|
||||
let mut child = cmd.spawn().expect("start tty7-server --daemon");
|
||||
|
||||
let stderr = BufReader::new(child.stderr.take().expect("piped"));
|
||||
let mut bound = None;
|
||||
for line in stderr.lines().map_while(Result::ok) {
|
||||
if let Some(path) = line.strip_prefix("tty7-server: control socket at ") {
|
||||
bound = Some(path.to_string());
|
||||
break;
|
||||
}
|
||||
// The listener reports its own failures on the same stream; a test that
|
||||
// silently timed out here would be far harder to read than one that
|
||||
// says what the server said.
|
||||
assert!(
|
||||
!line.contains("control listener unavailable"),
|
||||
"the server could not bind at all: {line}"
|
||||
);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
bound.expect("the server prints the control socket it bound")
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! **A remote workspace's pane, end to end, with no sshd and no network.**
|
||||
//!
|
||||
//! `remote_router.rs` proves the *control* dialect crosses the router; this file
|
||||
//! proves the other one — the pane protocol — which is the half a remote
|
||||
//! workspace needs before it can run anything at all. Until it existed a remote
|
||||
//! window opened, listed files, and could not spawn a terminal.
|
||||
//!
|
||||
//! ## What stands in for what
|
||||
//!
|
||||
//! | Real thing | Here |
|
||||
//! |---|---|
|
||||
//! | The GUI's `RemoteTerminal` | a `UnixStream` speaking `ClientMsg`/`DaemonMsg` |
|
||||
//! | The user's local daemon | a `UnixListener` + `RemoteRouter::route` |
|
||||
//! | The SSH channel | `RouteTarget::LocalStdio` → a child process |
|
||||
//! | The remote `tty7-server --daemon` | `tty7-server --stdio --pane` bridging to one |
|
||||
//!
|
||||
//! Only the middle hop is faked, and it is faked with the same
|
||||
//! `RemoteRouter::route` the daemon calls. Everything on the far side is the
|
||||
//! real binary: a real `--daemon` process, a real PTY, a real shell.
|
||||
//!
|
||||
//! ## Why `--config-dir` per test
|
||||
//!
|
||||
//! The "remote" pane daemon this stands up is a *real* daemon on this machine.
|
||||
//! Pointing it at a temp config dir gives it its own socket, so it can neither
|
||||
//! see nor be seen by the developer's own tty7 — and `Shutdown` at the end of
|
||||
//! each test reaps it rather than leaving one per CI run.
|
||||
|
||||
// Unix-only for the same reason `remote_router.rs` is: the hop being tested is a
|
||||
// Unix-domain socket, and `--stdio` is a Unix path by construction.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tty7_core::daemon::protocol::{ClientMsg, DaemonMsg, ShellSpec, WinSize};
|
||||
use tty7_core::daemon::router::{RemoteRouter, RouteChannel, RouteHeader, negotiate};
|
||||
|
||||
const EXE: &str = env!("CARGO_BIN_EXE_tty7-server");
|
||||
|
||||
/// How long a test waits for a shell to say something. Generous: a cold daemon
|
||||
/// launch plus a shell start on a loaded CI box is not instant, and a flaky
|
||||
/// timeout here would read as a routing bug.
|
||||
const OUTPUT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn win() -> WinSize {
|
||||
WinSize {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_w: 8,
|
||||
cell_h: 17,
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell with no startup files, so what comes back is the command's output and
|
||||
/// not somebody's prompt theme.
|
||||
fn plain_shell() -> ShellSpec {
|
||||
ShellSpec {
|
||||
program: "/bin/sh".to_string(),
|
||||
args: Vec::new(),
|
||||
args_are_tty7_defaults: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stand up the local hop: a socket that routes one connection and then returns.
|
||||
///
|
||||
/// One connection per hub, because that is exactly what the GUI does — a pane is
|
||||
/// a connection, and `handle_conn` hands each one to the router separately.
|
||||
fn hub(dir: &Path, name: &str) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
|
||||
let path = dir.join(name);
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = stream.try_clone().unwrap();
|
||||
let (kind, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap();
|
||||
assert_eq!(kind, tty7_core::daemon::router::ROUTE_KIND);
|
||||
let header = RouteHeader::decode(&payload).unwrap();
|
||||
// The far end outliving the near one is normal (the client hangs up
|
||||
// first), so a closed pipe here is not a failure.
|
||||
let _ = RemoteRouter::route(stream, &header);
|
||||
});
|
||||
(path, thread)
|
||||
}
|
||||
|
||||
/// The header a pane of a remote workspace writes, with this machine standing in
|
||||
/// for the remote.
|
||||
fn pane_header(config_dir: &Path) -> RouteHeader {
|
||||
RouteHeader::local_stdio(
|
||||
EXE,
|
||||
&[
|
||||
"--stdio",
|
||||
"--pane",
|
||||
"--config-dir",
|
||||
&config_dir.to_string_lossy(),
|
||||
],
|
||||
)
|
||||
.for_pane()
|
||||
}
|
||||
|
||||
/// Open a routed pane connection through a fresh hub.
|
||||
fn routed(dir: &Path, name: &str, config_dir: &Path) -> (UnixStream, std::thread::JoinHandle<()>) {
|
||||
let (path, thread) = hub(dir, name);
|
||||
let mut sock = UnixStream::connect(&path).unwrap();
|
||||
let ack = negotiate(&mut sock, &pane_header(config_dir)).expect("the route should be accepted");
|
||||
assert_eq!(ack.link.as_deref(), Some("local-stdio"));
|
||||
(sock, thread)
|
||||
}
|
||||
|
||||
/// Read frames until `needle` shows up in the accumulated PTY bytes.
|
||||
///
|
||||
/// Accumulating rather than matching per frame is the point: a PTY splits output
|
||||
/// wherever it likes, and a test that expected one frame per line would pass or
|
||||
/// fail on scheduling.
|
||||
fn read_until(sock: &mut UnixStream, needle: &str) -> String {
|
||||
let deadline = Instant::now() + OUTPUT_TIMEOUT;
|
||||
let mut seen = String::new();
|
||||
sock.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.unwrap();
|
||||
while Instant::now() < deadline {
|
||||
match DaemonMsg::read(sock) {
|
||||
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
|
||||
seen.push_str(&String::from_utf8_lossy(&bytes));
|
||||
if seen.contains(needle) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||
Err(e) => panic!("stream died waiting for {needle:?}: {e}\nsaw: {seen:?}"),
|
||||
}
|
||||
}
|
||||
panic!("timed out waiting for {needle:?}\nsaw: {seen:?}");
|
||||
}
|
||||
|
||||
/// Stop the daemon this test started, so it does not outlive the run.
|
||||
fn shutdown(dir: &Path, config_dir: &Path) {
|
||||
let (path, thread) = hub(dir, "shutdown.sock");
|
||||
if let Ok(mut sock) = UnixStream::connect(&path)
|
||||
&& negotiate(&mut sock, &pane_header(config_dir)).is_ok()
|
||||
{
|
||||
let _ = ClientMsg::Shutdown.encode(&mut sock);
|
||||
// The daemon exits without replying, so read to EOF rather than
|
||||
// expecting a frame.
|
||||
let _ = sock.read(&mut [0u8; 64]);
|
||||
}
|
||||
let _ = thread.join();
|
||||
}
|
||||
|
||||
/// **The milestone's proof.** Open a pane on the "remote", type at it, see what
|
||||
/// it printed, hang up, come back, and find the pane still there with its
|
||||
/// scrollback.
|
||||
///
|
||||
/// Every claim a remote workspace makes is in this one test: the pane exists on
|
||||
/// the far machine (it survives the connection that made it), the hot path
|
||||
/// crosses the router intact in both directions, and reattach finds the same
|
||||
/// pane rather than a new one.
|
||||
#[test]
|
||||
fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let config = dir.path().join("remote-config");
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
|
||||
// ---- connect, spawn ---------------------------------------------------
|
||||
let (mut sock, hub_thread) = routed(dir.path(), "pane-1.sock", &config);
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(dir.path().to_path_buf()),
|
||||
size: win(),
|
||||
shell: Some(plain_shell()),
|
||||
}
|
||||
.encode(&mut sock)
|
||||
.unwrap();
|
||||
|
||||
let pane_id = match DaemonMsg::read(&mut sock).unwrap() {
|
||||
DaemonMsg::Spawned { pane_id } => pane_id,
|
||||
other => panic!("expected Spawned through the router, got {other:?}"),
|
||||
};
|
||||
|
||||
// ---- input → output ---------------------------------------------------
|
||||
// A marker no shell prompt would produce on its own, echoed by a command
|
||||
// that exists in every POSIX shell.
|
||||
ClientMsg::Input(b"echo rou''ted-pane-alive\n".to_vec())
|
||||
.encode(&mut sock)
|
||||
.unwrap();
|
||||
read_until(&mut sock, "routed-pane-alive");
|
||||
|
||||
// ---- disconnect -------------------------------------------------------
|
||||
// `Detach`, not `Kill`: the pane is meant to keep running on the far side,
|
||||
// which is the entire proposition of a remote workspace.
|
||||
ClientMsg::Detach.encode(&mut sock).unwrap();
|
||||
drop(sock);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
// ---- reconnect --------------------------------------------------------
|
||||
let (mut back, hub_thread) = routed(dir.path(), "pane-2.sock", &config);
|
||||
ClientMsg::Attach {
|
||||
pane_id,
|
||||
size: win(),
|
||||
}
|
||||
.encode(&mut back)
|
||||
.unwrap();
|
||||
|
||||
// The snapshot replays the ring the *remote* daemon kept, so the marker
|
||||
// printed before the disconnect is still there.
|
||||
read_until(&mut back, "routed-pane-alive");
|
||||
|
||||
// And it is live, not just a recording.
|
||||
ClientMsg::Input(b"echo st''ill-here\n".to_vec())
|
||||
.encode(&mut back)
|
||||
.unwrap();
|
||||
read_until(&mut back, "still-here");
|
||||
|
||||
drop(back);
|
||||
let _ = hub_thread.join();
|
||||
shutdown(dir.path(), &config);
|
||||
}
|
||||
|
||||
/// The pane channel and the control channel are **not** interchangeable.
|
||||
///
|
||||
/// A header that forgets `for_pane()` reaches the control socket, where a
|
||||
/// `Spawn` is an unknown frame. This is what "the window opens but nothing runs
|
||||
/// in it" looked like, so it is pinned rather than left to the reader.
|
||||
#[test]
|
||||
fn the_channel_decides_which_dialect_the_route_carries() {
|
||||
let control = RouteHeader::local_stdio(EXE, &["--stdio"]);
|
||||
assert_eq!(control.channel, RouteChannel::Control);
|
||||
assert_eq!(control.clone().for_pane().channel, RouteChannel::Pane);
|
||||
|
||||
// The wire tag is what a *different* build matches on, so it is pinned
|
||||
// rather than left to the variant name — and the default has to keep
|
||||
// decoding as `control`, because that is what every header written before
|
||||
// the field existed meant.
|
||||
let mut buf = Vec::new();
|
||||
control.clone().for_pane().write(&mut buf).unwrap();
|
||||
let (_, payload) = tty7_core::daemon::protocol::read_frame(&mut buf.as_slice()).unwrap();
|
||||
let json = String::from_utf8(payload).unwrap();
|
||||
assert!(json.contains(r#""channel":"pane""#), "{json}");
|
||||
|
||||
let legacy = r#"{"target":{"local_stdio":{"program":"x","args":[]}}}"#;
|
||||
let decoded = RouteHeader::decode(legacy.as_bytes()).unwrap();
|
||||
assert_eq!(decoded.channel, RouteChannel::Control);
|
||||
}
|
||||
|
||||
/// A routed pane's `Kill` reaches the machine the pane is on.
|
||||
///
|
||||
/// Pane ids are per-daemon, so this is not a convenience: an unrouted `Kill`
|
||||
/// does not fail, it succeeds against whatever local pane happens to hold the
|
||||
/// same number.
|
||||
#[test]
|
||||
fn a_routed_kill_reaches_the_pane_it_names() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let config = dir.path().join("remote-config");
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
|
||||
let (mut sock, hub_thread) = routed(dir.path(), "pane-1.sock", &config);
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(dir.path().to_path_buf()),
|
||||
size: win(),
|
||||
shell: Some(plain_shell()),
|
||||
}
|
||||
.encode(&mut sock)
|
||||
.unwrap();
|
||||
let pane_id = match DaemonMsg::read(&mut sock).unwrap() {
|
||||
DaemonMsg::Spawned { pane_id } => pane_id,
|
||||
other => panic!("expected Spawned, got {other:?}"),
|
||||
};
|
||||
ClientMsg::Detach.encode(&mut sock).unwrap();
|
||||
drop(sock);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
// It is on the remote's registry...
|
||||
let (mut list, hub_thread) = routed(dir.path(), "list-1.sock", &config);
|
||||
ClientMsg::List.encode(&mut list).unwrap();
|
||||
let before = match DaemonMsg::read(&mut list).unwrap() {
|
||||
DaemonMsg::PaneList(panes) => panes,
|
||||
other => panic!("expected PaneList, got {other:?}"),
|
||||
};
|
||||
assert!(before.iter().any(|p| p.pane_id == pane_id), "{before:?}");
|
||||
drop(list);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
// ...and a routed Kill takes it off.
|
||||
let (mut kill, hub_thread) = routed(dir.path(), "kill-1.sock", &config);
|
||||
ClientMsg::Kill { pane_id }.encode(&mut kill).unwrap();
|
||||
let _ = kill.shutdown(std::net::Shutdown::Write);
|
||||
drop(kill);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
let (mut list, hub_thread) = routed(dir.path(), "list-2.sock", &config);
|
||||
ClientMsg::List.encode(&mut list).unwrap();
|
||||
let after = match DaemonMsg::read(&mut list).unwrap() {
|
||||
DaemonMsg::PaneList(panes) => panes,
|
||||
other => panic!("expected PaneList, got {other:?}"),
|
||||
};
|
||||
assert!(!after.iter().any(|p| p.pane_id == pane_id), "{after:?}");
|
||||
drop(list);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
shutdown(dir.path(), &config);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! **The milestone's proof**: every `Host` conformance case, run against a real
|
||||
//! `tty7-server --stdio` child process over real pipes.
|
||||
//!
|
||||
//! Not a mock, not an in-process socket pair, and — the part that matters — not
|
||||
//! an sshd. The client is `RemoteHost`, the wire is the control dialect, the
|
||||
//! server is the shipped binary answering out of its own address space, and the
|
||||
//! only thing standing in for SSH is a pair of pipes. Everything between the
|
||||
//! `Host` call and the syscall is the code a transcontinental workspace runs.
|
||||
//!
|
||||
//! That is what makes remote workspaces testable in CI at all. The alternative —
|
||||
//! provisioning a machine, an sshd, a key, and a network for every pull request
|
||||
//! — is expensive enough that in practice it does not get run, which means the
|
||||
//! two `Host` implementations drift and nobody finds out until someone opens a
|
||||
//! remote directory. Here the identical list of cases runs against `LocalHost`
|
||||
//! in `tty7-core` and against this, and a divergence is a red test.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! One child process and one sandbox **per case**, via
|
||||
//! [`host_conformance_suite!`](tty7_core::host_conformance_suite). Spawning
|
||||
//! forty-six servers costs a few hundred milliseconds in total and buys complete
|
||||
//! isolation: no case can be explained by another's leftover state, a hung
|
||||
//! server fails exactly one case, and a crash names the behaviour that caused it.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tty7_core::daemon::control::{ControlHello, LinkShutdown};
|
||||
use tty7_core::host::SharedHost;
|
||||
use tty7_core::host::conformance::Sandbox;
|
||||
use tty7_core::host::remote::RemoteHost;
|
||||
|
||||
/// The child, and the only way to end it.
|
||||
///
|
||||
/// `RemoteHost` closes its link through [`LinkShutdown`]; for a socket that is
|
||||
/// `shutdown(2)`, and for a child process it is this. Without it, dropping the
|
||||
/// host would leave the reader thread parked on a pipe the server has no reason
|
||||
/// to write to and the server parked on a pipe the client has no reason to write
|
||||
/// to — the exact standoff `LinkShutdown` exists to break, one transport over.
|
||||
struct ServerProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl LinkShutdown for ServerProcess {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
||||
return Ok(()); // already reaped; `close` and `Drop` both call this
|
||||
};
|
||||
let _ = child.kill();
|
||||
// Reaped here rather than left to the OS: forty-six cases running in
|
||||
// parallel would otherwise accumulate forty-six zombies for the life of
|
||||
// the test binary.
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A temp directory on the machine the server is on — which, this being the
|
||||
/// stdio path, is also this one.
|
||||
struct TempSandbox(tempfile::TempDir);
|
||||
|
||||
impl Sandbox for TempSandbox {
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
|
||||
fn symlink(&self, target: &Path, link: &Path) -> Option<io::Result<()>> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(std::os::unix::fs::symlink(target, link))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (target, link);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a server and connect a `RemoteHost` to it.
|
||||
fn stdio_host() -> (SharedHost, TempSandbox) {
|
||||
let sandbox = TempSandbox(tempfile::TempDir::new().unwrap());
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
// `--serve` rather than letting the mode be probed: a developer running
|
||||
// these tests may well have a real `tty7-server --daemon` up, and a
|
||||
// bridge to *that* would be testing their machine's state instead of
|
||||
// this build.
|
||||
.args(["--stdio", "--serve"])
|
||||
// The server opens its workspace store at startup. None of these cases
|
||||
// touch it, but pointing it at the sandbox keeps forty-six child
|
||||
// processes off the developer's real `~/.local/share/tty7`.
|
||||
.env("TTY7_DATA_DIR", sandbox.path())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
// The server's diagnostics are not this test's output. A failure shows
|
||||
// up as a failed request, which names the case.
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start tty7-server --stdio");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
|
||||
let hello = ControlHello::host_rpc("stdio-conformance", "localhost");
|
||||
let host = RemoteHost::connect_with(stdout, stdin, Some(closer), "stdio:conformance", &hello)
|
||||
.expect("handshake with tty7-server --stdio");
|
||||
|
||||
(host.into_shared(), sandbox)
|
||||
}
|
||||
|
||||
// Every case in `tty7-core`'s shared suite, over the pipes. This is the same
|
||||
// list `LocalHost` runs; the point is that it is not a *similar* list.
|
||||
tty7_core::host_conformance_suite!(remote_stdio, stdio_host);
|
||||
|
||||
/// The suite above only proves the cases pass — it cannot prove they were the
|
||||
/// whole suite. This checks the count the registry actually carries, so a case
|
||||
/// silently dropped upstream shows up here as well as there.
|
||||
#[test]
|
||||
fn the_whole_suite_ran_against_the_server() {
|
||||
let names: Vec<&str> = tty7_core::host::conformance::CASES
|
||||
.iter()
|
||||
.map(|(n, _)| *n)
|
||||
.collect();
|
||||
assert!(
|
||||
names.len() >= 46,
|
||||
"the conformance suite shrank to {} cases: {names:?}",
|
||||
names.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The server is a *separate process* answering out of its own memory. Easy to
|
||||
/// lose by accident — an in-process fallback would keep every case above green
|
||||
/// while testing nothing that this milestone is about.
|
||||
#[test]
|
||||
fn the_server_really_is_another_process() {
|
||||
let (host, sandbox) = stdio_host();
|
||||
let marker = host.join(sandbox.path(), "written-over-the-wire.txt");
|
||||
host.write_file(&marker, b"from the client").unwrap();
|
||||
|
||||
// This side reads it with plain `std::fs`: if the bytes are there, they went
|
||||
// out through a pipe and came back through a syscall someone else made.
|
||||
assert_eq!(std::fs::read(&marker).unwrap(), b"from the client");
|
||||
|
||||
// And the reverse: a change this process makes with `std::fs` is visible to
|
||||
// the server, so both ends really are looking at one filesystem through two
|
||||
// different code paths.
|
||||
let from_here = sandbox.path().join("written-locally.txt");
|
||||
std::fs::write(&from_here, b"from the test").unwrap();
|
||||
assert_eq!(
|
||||
host.read_file(&from_here, 1024).unwrap(),
|
||||
b"from the test",
|
||||
"the server read a file this process wrote"
|
||||
);
|
||||
assert!(host.is_connected());
|
||||
}
|
||||
|
||||
/// Dropping the host kills the child. A test binary that leaked one server per
|
||||
/// case would leave forty-six processes behind on every run.
|
||||
#[test]
|
||||
fn dropping_the_host_reaps_the_server() {
|
||||
let (host, sandbox) = stdio_host();
|
||||
assert!(host.exists(sandbox.path()));
|
||||
drop(host);
|
||||
// Nothing to assert beyond "this returns": the reap happens inside the drop,
|
||||
// and a shutdown that did not wake the reader would hang here instead.
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! The workspace store, end to end against a real `tty7-server` child process.
|
||||
//!
|
||||
//! Same shape and the same reasoning as [`stdio_conformance`]: the client is
|
||||
//! the shipped `ControlClient`, the wire is the control dialect over real
|
||||
//! pipes, and the server is the shipped binary keeping its records in a file it
|
||||
//! owns. What this file adds is the half the conformance suite cannot reach —
|
||||
//! the store is not a `Host` method, so no amount of `read_dir` parity proves
|
||||
//! that `WorkspacePut` reached a disk or that another client heard about it.
|
||||
//!
|
||||
//! The three things worth a process boundary:
|
||||
//!
|
||||
//! | | Why an in-process socket pair would not do |
|
||||
//! |---|---|
|
||||
//! | The record is on **the server's** disk | The whole storage split (design §10) is "the machine is the authority". A store in the test's own address space proves nothing about that |
|
||||
//! | `workspace-store` is advertised only when served | The capability bit is built from what the *binary* wires up, and that wiring lives in `main.rs` |
|
||||
//! | A change reaches the **other** connection | Two clients, one server process, one file — the configuration the user actually has when their laptop and their desktop are both connected |
|
||||
//!
|
||||
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
|
||||
//! another's leftovers and nothing here can touch the developer's real
|
||||
//! `~/.local/share/tty7/workspaces.json`.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tty7_core::core::workspace_store::{STORE_FILE, WorkspaceStore};
|
||||
use tty7_core::daemon::control::{
|
||||
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, feature,
|
||||
};
|
||||
use tty7_core::host::local::LocalHost;
|
||||
use tty7_core::host::server;
|
||||
|
||||
/// The child, and the only way to end it — see `stdio_conformance.rs` for why a
|
||||
/// `LinkShutdown` is what reaps a process-backed link.
|
||||
struct ServerProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl LinkShutdown for ServerProcess {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One connected client: the RPC channel, plus everything the server pushed to
|
||||
/// it.
|
||||
struct Client {
|
||||
control: ControlClient,
|
||||
events: Arc<Mutex<Vec<ControlEvent>>>,
|
||||
peer_features: Vec<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Wait for a `WorkspaceChanged` naming `id`, or fail saying what did
|
||||
/// arrive. Polled rather than blocked on a channel because the event and
|
||||
/// the reply that caused it race by construction.
|
||||
fn expect_changed(&self, id: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let seen = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if seen
|
||||
.iter()
|
||||
.any(|e| matches!(e, ControlEvent::WorkspaceChanged { id: got } if got == id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"no WorkspaceChanged for {id}; saw {seen:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the takeover notice naming `workspace` and `by`.
|
||||
fn expect_preempted(&self, workspace: &str, by: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let seen = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if seen.iter().any(|e| {
|
||||
matches!(e, ControlEvent::Preempted { workspace: w, by: b }
|
||||
if w == workspace && b == by)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"no Preempted for {workspace} by {by}; saw {seen:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_count(&self) -> usize {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ControlEvent::WorkspaceChanged { .. }))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a `tty7-server --stdio --serve` whose store lives in `data_dir`, and
|
||||
/// connect a client to it.
|
||||
///
|
||||
/// `--serve` rather than letting the mode be probed: a developer running these
|
||||
/// tests may well have a real `tty7-server --daemon` up, and bridging to *that*
|
||||
/// would be testing their machine's state — and, here, writing to their real
|
||||
/// workspace file.
|
||||
fn connect(data_dir: &Path, token: &str) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--serve"])
|
||||
.env("TTY7_DATA_DIR", data_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start tty7-server --stdio");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
|
||||
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&events);
|
||||
let control = ControlClient::connect_with(
|
||||
stdout,
|
||||
stdin,
|
||||
Some(closer),
|
||||
&ControlHello::host_rpc(token, "test-client"),
|
||||
Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)),
|
||||
)
|
||||
.expect("handshake with tty7-server --stdio");
|
||||
|
||||
let peer_features = control.hello().features.clone();
|
||||
Client {
|
||||
control,
|
||||
events,
|
||||
peer_features,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().unwrap()
|
||||
}
|
||||
|
||||
fn store_file(dir: &tempfile::TempDir) -> PathBuf {
|
||||
dir.path().join(STORE_FILE)
|
||||
}
|
||||
|
||||
fn record(id: &str, name: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"session": {"active": 0, "tabs": [
|
||||
{"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 11}},
|
||||
"sidebar_group": "/home/me/proj"}
|
||||
]},
|
||||
"last_active": 1_753_600_000u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn json(reply: ReplyOk) -> serde_json::Value {
|
||||
match reply {
|
||||
ReplyOk::Json(v) => v,
|
||||
other => panic!("expected a Json reply, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The capability bit is the client's cue that asking is worth a round trip, so
|
||||
/// it has to reflect what the shipped binary actually wired up.
|
||||
#[test]
|
||||
fn the_server_advertises_the_workspace_store() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "cap");
|
||||
assert!(
|
||||
client
|
||||
.peer_features
|
||||
.iter()
|
||||
.any(|f| f == feature::WORKSPACE_STORE),
|
||||
"features were {:?}",
|
||||
client.peer_features
|
||||
);
|
||||
}
|
||||
|
||||
/// **The milestone's proof for M5.** The four RPCs against a real server, and
|
||||
/// the record ends up in a file that server owns — the storage split is not a
|
||||
/// diagram, it is this file on that machine.
|
||||
#[test]
|
||||
fn records_survive_in_a_file_the_server_owns() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "rpc");
|
||||
|
||||
assert_eq!(
|
||||
json(client.control.call(ControlRequest::WorkspaceList).unwrap()),
|
||||
serde_json::json!([])
|
||||
);
|
||||
|
||||
for (id, name) in [("w-api", "api"), ("w-web", "web")] {
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: id.to_string(),
|
||||
json: record(id, name),
|
||||
})
|
||||
.expect("put");
|
||||
}
|
||||
|
||||
// The file is on this machine only because the "remote" is this machine;
|
||||
// the point is that the *test process* never wrote it. Reading it with
|
||||
// plain `std::fs` is how we know the bytes went out through a pipe and came
|
||||
// back as a syscall someone else made.
|
||||
let text = std::fs::read_to_string(store_file(&dir)).expect("the server wrote its store");
|
||||
assert!(text.contains("w-api"), "{text}");
|
||||
assert!(text.contains("w-web"), "{text}");
|
||||
|
||||
// Get answers exactly what was put.
|
||||
let got = json(
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "w-api".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(got, record("w-api", "api"));
|
||||
|
||||
// List answers both, in the order they were written.
|
||||
let listed = json(client.control.call(ControlRequest::WorkspaceList).unwrap());
|
||||
let ids: Vec<&str> = listed
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v["id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["w-api", "w-web"]);
|
||||
|
||||
// A missing id is an error the client can tell from an empty record.
|
||||
let missing = client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "not-a-workspace".to_string(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
|
||||
|
||||
// Delete reaches the disk, and deleting again is still success.
|
||||
for _ in 0..2 {
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceDelete {
|
||||
id: "w-api".to_string(),
|
||||
})
|
||||
.expect("delete");
|
||||
}
|
||||
let text = std::fs::read_to_string(store_file(&dir)).unwrap();
|
||||
assert!(!text.contains("w-api"), "{text}");
|
||||
assert!(text.contains("w-web"), "{text}");
|
||||
}
|
||||
|
||||
/// A second connection to the same server sees the first one's records — that
|
||||
/// is what "换台电脑连过来要看到同一份" means once the machine is fixed and the
|
||||
/// client is not.
|
||||
#[test]
|
||||
fn a_later_client_sees_what_an_earlier_one_wrote() {
|
||||
let dir = data_dir();
|
||||
{
|
||||
let first = connect(dir.path(), "first");
|
||||
first
|
||||
.control
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: "w".to_string(),
|
||||
json: record("w", "api"),
|
||||
})
|
||||
.expect("put");
|
||||
first.control.close();
|
||||
}
|
||||
|
||||
// A brand-new server process, reading the file the previous one left.
|
||||
let second = connect(dir.path(), "second");
|
||||
let got = json(
|
||||
second
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "w".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(got["name"], "api");
|
||||
assert_eq!(got["session"]["tabs"][0]["pane"]["Leaf"]["pane_id"], 11);
|
||||
}
|
||||
|
||||
/// Two clients on one machine at once. A change by one has to reach the other,
|
||||
/// and must not come back to its author.
|
||||
///
|
||||
/// The store lives behind a listener, as it does under `--daemon`, and both
|
||||
/// clients reach it as `--stdio --bridge` children — the same two-hop shape
|
||||
/// `cli.rs` uses, and the configuration a user has when their laptop and their
|
||||
/// desktop are both connected. A store per connection would pass every other
|
||||
/// test in this file and fail this one.
|
||||
#[test]
|
||||
fn a_change_from_one_client_reaches_the_other() {
|
||||
let dir = data_dir();
|
||||
let store = WorkspaceStore::open(store_file(&dir));
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
{
|
||||
let store = Arc::clone(&store);
|
||||
std::thread::spawn(move || {
|
||||
server::serve_listener_with(
|
||||
listener,
|
||||
LocalHost::new(),
|
||||
server::Services::with_workspaces(store),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let writer = bridged(&sock, "writer");
|
||||
let watcher = bridged(&sock, "watcher");
|
||||
assert!(
|
||||
writer
|
||||
.peer_features
|
||||
.iter()
|
||||
.any(|f| f == feature::WORKSPACE_STORE)
|
||||
);
|
||||
|
||||
writer
|
||||
.control
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: "shared".to_string(),
|
||||
json: record("shared", "api"),
|
||||
})
|
||||
.expect("put");
|
||||
|
||||
watcher.expect_changed("shared");
|
||||
assert_eq!(
|
||||
writer.changed_count(),
|
||||
0,
|
||||
"a client must not be pushed its own change"
|
||||
);
|
||||
|
||||
// The watcher is looking at the same store, not at a copy.
|
||||
let got = json(
|
||||
watcher
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "shared".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(got["name"], "api");
|
||||
|
||||
// A delete is a change too — and the watcher's own delete comes back to the
|
||||
// writer, which is the same rule seen from the other side.
|
||||
watcher
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceDelete {
|
||||
id: "shared".to_string(),
|
||||
})
|
||||
.expect("delete");
|
||||
writer.expect_changed("shared");
|
||||
assert_eq!(watcher.changed_count(), 1, "still only the writer's put");
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
/// **Design §10's takeover, across two real processes.**
|
||||
///
|
||||
/// The same two-client shape as the change-notification test, and for the same
|
||||
/// reason: a takeover is by definition something one connection does to
|
||||
/// *another*, so an in-process registry with two handles into it would prove
|
||||
/// only that the data structure works. What has to hold is that the notice
|
||||
/// crosses a pipe into a different program and that the displaced link actually
|
||||
/// closes.
|
||||
///
|
||||
/// D8 is the assertion in the middle: the newcomer holds the workspace
|
||||
/// afterwards. Rejecting the second client would satisfy "only one at a time"
|
||||
/// just as well and is the decision this test exists to rule out.
|
||||
#[test]
|
||||
fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() {
|
||||
let dir = data_dir();
|
||||
let store = WorkspaceStore::open(store_file(&dir));
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
{
|
||||
let store = Arc::clone(&store);
|
||||
std::thread::spawn(move || {
|
||||
server::serve_listener_with(
|
||||
listener,
|
||||
LocalHost::new(),
|
||||
server::Services::with_workspaces(store),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let laptop = bridged_for(&sock, "tok-laptop", "laptop", Some("w"));
|
||||
// The attach runs on the server thread after the handshake reply, so the
|
||||
// record is what says it happened — not the fact that we got a `HELLO_OK`.
|
||||
await_attachment(&store, "w", "laptop");
|
||||
assert!(laptop.control.call(ControlRequest::Ping).is_ok());
|
||||
|
||||
let desktop = bridged_for(&sock, "tok-desktop", "desktop", Some("w"));
|
||||
|
||||
// The displaced client is told which workspace it lost and to whom.
|
||||
laptop.expect_preempted("w", "desktop");
|
||||
// …and then its link is closed, because this connection existed for that
|
||||
// workspace. Design §10: "关闭它的流".
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while laptop.control.is_connected() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the displaced session's link stayed open"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert_eq!(
|
||||
laptop
|
||||
.control
|
||||
.call(ControlRequest::Ping)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::ConnectionReset
|
||||
);
|
||||
|
||||
// D8: the newcomer is the one holding it, and it can still work.
|
||||
await_attachment(&store, "w", "desktop");
|
||||
assert!(desktop.control.call(ControlRequest::Ping).is_ok());
|
||||
assert_eq!(
|
||||
desktop.changed_count(),
|
||||
0,
|
||||
"taking over is not a workspace change"
|
||||
);
|
||||
|
||||
// Taking it back is the same operation in the other direction — that is all
|
||||
// the [Take Back] button is.
|
||||
let back = bridged_for(&sock, "tok-laptop-2", "laptop", None);
|
||||
let reply = back
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceAttach { id: "w".into() })
|
||||
.expect("attach");
|
||||
assert_eq!(
|
||||
reply,
|
||||
ReplyOk::Attached {
|
||||
took_over_from: Some("desktop".to_string())
|
||||
}
|
||||
);
|
||||
desktop.expect_preempted("w", "laptop");
|
||||
await_attachment(&store, "w", "laptop");
|
||||
|
||||
// The link that just did the taking was not opened *for* the workspace, so
|
||||
// it is a plain machine connection and keeps working — that is the shape the
|
||||
// GUI has, one link per machine.
|
||||
assert!(back.control.is_connected());
|
||||
assert!(back.control.call(ControlRequest::Ping).is_ok());
|
||||
}
|
||||
|
||||
/// Poll until `hostname` holds `workspace`, or fail saying who does.
|
||||
fn await_attachment(store: &Arc<WorkspaceStore>, workspace: &str, hostname: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let who = store.attachment(workspace);
|
||||
if who.as_ref().map(|a| a.hostname.as_str()) == Some(hostname) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"{workspace} is held by {who:?}, not {hostname}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// A `--stdio --bridge` child connected to an already-listening control socket.
|
||||
fn bridged(sock: &Path, token: &str) -> Client {
|
||||
bridged_for(sock, token, "test-client", None)
|
||||
}
|
||||
|
||||
/// [`bridged`], naming the client machine and, optionally, the workspace this
|
||||
/// connection is opened *for* — the hello field design §10's takeover keys on.
|
||||
fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client {
|
||||
let hello = ControlHello {
|
||||
control_version: tty7_core::daemon::control::CONTROL_VERSION,
|
||||
workspace: workspace.map(str::to_string),
|
||||
client_token: token.to_string(),
|
||||
client_hostname: hostname.to_string(),
|
||||
};
|
||||
bridged_with(sock, hello)
|
||||
}
|
||||
|
||||
fn bridged_with(sock: &Path, hello: ControlHello) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--bridge", "--control-sock"])
|
||||
.arg(sock)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start the bridging client");
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&events);
|
||||
let control = ControlClient::connect_with(
|
||||
stdout,
|
||||
stdin,
|
||||
Some(closer),
|
||||
&hello,
|
||||
Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)),
|
||||
)
|
||||
.expect("bridge handshake");
|
||||
let peer_features = control.hello().features.clone();
|
||||
Client {
|
||||
control,
|
||||
events,
|
||||
peer_features,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
# 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
@@ -0,0 +1,122 @@
|
||||
# `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.
|
||||
@@ -24,6 +24,10 @@ actions!(
|
||||
// Until now `Workspace.name` could only ever be the derived repo name —
|
||||
// there was no way for the user to set one.
|
||||
RenameWorkspace,
|
||||
// Open the workspace switcher: every workspace on every machine, in one
|
||||
// panel. The title-bar chip opens the same thing, so this is the
|
||||
// keyboard's half of a control that is otherwise mouse-only.
|
||||
ToggleSwitcher,
|
||||
// Show the Nth workspace in the Window menu's order (see
|
||||
// `ui::windows::menu_order`). Unit actions rather than one
|
||||
// parameterized action, matching `ActivateTab1..9` — it keeps them
|
||||
|
||||
+69
-1517
File diff suppressed because it is too large
Load Diff
+34
-161
@@ -1,123 +1,27 @@
|
||||
//! The SSH credential vault: an abstraction over the OS keychain.
|
||||
//! The *storage* half of the SSH credential vault: the [`CredentialStore`]
|
||||
//! trait, its OS-keychain backend and the in-memory test double.
|
||||
//!
|
||||
//! Secrets (passwords, private-key passphrases) live only in the platform secret
|
||||
//! store — never in `config.json`. A profile persists at most a [`CredentialRef`],
|
||||
//! which *names* a keychain entry but carries no secret. Per PRD §7.2 entries are
|
||||
//! keyed by **endpoint**, not by profile:
|
||||
//! The naming half — [`CredentialKind`], [`CredentialRef`], [`endpoint_account`]
|
||||
//! and the two service constants — lives one crate down in
|
||||
//! `tty7_core::core::keychain` and is re-exported here, so every call site keeps
|
||||
//! using `crate::core::keychain::…` for both halves.
|
||||
//!
|
||||
//! - passwords → service `tty7-ssh`, account `<user>@<host>:<port>`
|
||||
//! - key passphrases → service `tty7-ssh-key`, account `<sha512-hex of key file>`
|
||||
//!
|
||||
//! Endpoint keying lets a QuickConnect (which has no profile) still "remember" a
|
||||
//! password, lets several profiles pointing at one endpoint share one credential,
|
||||
//! and means changing a password touches exactly one entry.
|
||||
//!
|
||||
//! The [`CredentialStore`] trait keeps the backend swappable: [`OsCredentialStore`]
|
||||
//! talks to the real keychain (macOS Keychain / Windows Credential Manager / Linux
|
||||
//! Secret Service via the `keyring` crate), while [`InMemoryCredentialStore`] backs
|
||||
//! tests without touching the machine's keychain.
|
||||
//! **Why the split.** `tty7-core` also builds the headless `tty7-server`, a
|
||||
//! static binary meant to be small enough to push onto an arbitrary box. That
|
||||
//! machine has no OS keychain and nothing in `tty7-core` ever reads a secret —
|
||||
//! the daemon receives secrets already resolved by the GUI (see
|
||||
//! `daemon::protocol`'s `NativeSshSpec`). Leaving `keyring` in the core manifest
|
||||
//! made the server link `zbus` / `secret-service` and thirty-odd crates behind
|
||||
//! them for code it can never call. So the store moved up here, where its callers
|
||||
//! already were (`ui::ssh_prompt`, `ui::ssh_connect`, `ui::settings`, `ui::app`).
|
||||
//!
|
||||
//! Secrets are never logged. The typed helpers below deliberately keep secret
|
||||
//! values out of `Debug`/log output.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
/// Keychain service name for endpoint passwords.
|
||||
pub const SERVICE_PASSWORD: &str = "tty7-ssh";
|
||||
/// Keychain service name for private-key passphrases.
|
||||
pub const SERVICE_KEY_PASSPHRASE: &str = "tty7-ssh-key";
|
||||
|
||||
/// Which kind of secret a [`CredentialRef`] points at. The kind selects the
|
||||
/// keychain *service*; the ref's `account` selects the entry within it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CredentialKind {
|
||||
/// An endpoint password (`tty7-ssh` service, `user@host:port` account).
|
||||
#[default]
|
||||
Password,
|
||||
/// A private-key passphrase (`tty7-ssh-key` service, key-sha512-hex account).
|
||||
KeyPassphrase,
|
||||
}
|
||||
|
||||
impl CredentialKind {
|
||||
/// The keychain service name this kind stores under.
|
||||
pub fn service(self) -> &'static str {
|
||||
match self {
|
||||
CredentialKind::Password => SERVICE_PASSWORD,
|
||||
CredentialKind::KeyPassphrase => SERVICE_KEY_PASSPHRASE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A persisted, secret-free pointer to a keychain entry. This is the only
|
||||
/// credential-related thing that ever lands in `config.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CredentialRef {
|
||||
/// Whether this names a password or a key passphrase.
|
||||
#[serde(deserialize_with = "crate::core::config::de_lenient")]
|
||||
pub kind: CredentialKind,
|
||||
/// The keychain "account": `user@host:port` for [`CredentialKind::Password`],
|
||||
/// or the sha512-hex of the key-file contents for
|
||||
/// [`CredentialKind::KeyPassphrase`].
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
impl Default for CredentialRef {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::Password,
|
||||
account: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialRef {
|
||||
/// Reference the password entry for an endpoint.
|
||||
pub fn password(user: &str, host: &str, port: u16) -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::Password,
|
||||
account: endpoint_account(user, host, port),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference the passphrase entry for a private key, given the sha512-hex of
|
||||
/// its file contents (see [`key_account_from_contents`]).
|
||||
pub fn key_passphrase(key_sha512_hex: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::KeyPassphrase,
|
||||
account: key_sha512_hex.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The keychain service this ref resolves under.
|
||||
pub fn service(&self) -> &'static str {
|
||||
self.kind.service()
|
||||
}
|
||||
}
|
||||
|
||||
/// The endpoint account string used to key a password entry: `user@host:port`.
|
||||
pub fn endpoint_account(user: &str, host: &str, port: u16) -> String {
|
||||
format!("{user}@{host}:{port}")
|
||||
}
|
||||
|
||||
/// The account string used to key a private-key passphrase entry: the lowercase
|
||||
/// sha512-hex digest of the key file's raw contents. Endpoint-independent, so the
|
||||
/// same encrypted key reused across hosts shares one stored passphrase.
|
||||
pub fn key_account_from_contents(key_bytes: &[u8]) -> String {
|
||||
let digest = Sha512::digest(key_bytes);
|
||||
// Lowercase hex, no separators.
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
pub use tty7_core::core::keychain::{
|
||||
CredentialKind, CredentialRef, SERVICE_KEY_PASSPHRASE, SERVICE_PASSWORD, endpoint_account,
|
||||
key_account_from_contents,
|
||||
};
|
||||
|
||||
/// A backend failure while talking to the credential store. Intentionally never
|
||||
/// carries a secret value — only a human-readable reason from the backend.
|
||||
@@ -200,17 +104,25 @@ pub trait CredentialStore: Send + Sync {
|
||||
Ok(CredentialRef::key_passphrase(key_sha512_hex.to_string()))
|
||||
}
|
||||
|
||||
// The three below are unused outside tests today. Unlike `tty7-core`, this is a
|
||||
// *binary* crate, where `pub` does not escape and `dead_code` therefore fires
|
||||
// on them; they are kept because the trait's five verbs (`password_*`,
|
||||
// `*_key_passphrase`, `*_ref`) only make sense as a set — a store you can
|
||||
// write a ref to but not read one back from is a trap for the next caller.
|
||||
/// Delete the stored passphrase for a private key (idempotent).
|
||||
#[allow(dead_code)]
|
||||
fn delete_key_passphrase(&self, key_sha512_hex: &str) -> CredentialResult<()> {
|
||||
self.delete(SERVICE_KEY_PASSPHRASE, key_sha512_hex)
|
||||
}
|
||||
|
||||
/// Resolve a [`CredentialRef`] to its secret, or `Ok(None)` if absent.
|
||||
#[allow(dead_code)]
|
||||
fn get_ref(&self, cref: &CredentialRef) -> CredentialResult<Option<String>> {
|
||||
self.get(cref.service(), &cref.account)
|
||||
}
|
||||
|
||||
/// Delete the entry a [`CredentialRef`] names (idempotent).
|
||||
#[allow(dead_code)]
|
||||
fn delete_ref(&self, cref: &CredentialRef) -> CredentialResult<()> {
|
||||
self.delete(cref.service(), &cref.account)
|
||||
}
|
||||
@@ -255,13 +167,19 @@ impl CredentialStore for OsCredentialStore {
|
||||
}
|
||||
|
||||
/// An in-memory store for tests. Never touches the OS keychain.
|
||||
///
|
||||
/// `#[cfg(test)]` because this crate is a binary: a test-only type left in a
|
||||
/// normal build is dead code here, where in `tty7-core` (a library) `pub` alone
|
||||
/// kept the lint quiet.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryCredentialStore {
|
||||
// Keyed by (service, account). Behind a Mutex so the store is `Sync` and can
|
||||
// be shared like the real one.
|
||||
entries: Mutex<HashMap<(String, String), String>>,
|
||||
entries: std::sync::Mutex<std::collections::HashMap<(String, String), String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl InMemoryCredentialStore {
|
||||
/// A fresh, empty store.
|
||||
pub fn new() -> Self {
|
||||
@@ -282,6 +200,7 @@ impl InMemoryCredentialStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl CredentialStore for InMemoryCredentialStore {
|
||||
fn get(&self, service: &str, account: &str) -> CredentialResult<Option<String>> {
|
||||
let entries = self.entries.lock().expect("credential store poisoned");
|
||||
@@ -310,52 +229,6 @@ impl CredentialStore for InMemoryCredentialStore {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_and_key_accounts_are_stable() {
|
||||
assert_eq!(
|
||||
endpoint_account("deploy", "10.0.0.5", 22),
|
||||
"deploy@10.0.0.5:22"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_account("deploy", "10.0.0.5", 2222),
|
||||
"deploy@10.0.0.5:2222"
|
||||
);
|
||||
|
||||
// sha512 hex is 128 chars, lowercase, and deterministic.
|
||||
let a = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n");
|
||||
let b = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), 128);
|
||||
assert!(
|
||||
a.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
);
|
||||
assert_ne!(a, key_account_from_contents(b"different"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_kind_selects_service() {
|
||||
assert_eq!(CredentialKind::Password.service(), "tty7-ssh");
|
||||
assert_eq!(CredentialKind::KeyPassphrase.service(), "tty7-ssh-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_ref_round_trips_and_hides_secret() {
|
||||
let cref = CredentialRef::password("deploy", "10.0.0.5", 22);
|
||||
let json = serde_json::to_string(&cref).unwrap();
|
||||
// Only kind + account are serialized — never a secret.
|
||||
assert!(json.contains("deploy@10.0.0.5:22"));
|
||||
assert!(json.contains("password"));
|
||||
let back: CredentialRef = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, cref);
|
||||
|
||||
// A bad `kind` value falls back to the default rather than failing the parse.
|
||||
let lenient: CredentialRef =
|
||||
serde_json::from_str(r#"{"kind":"bogus","account":"x"}"#).unwrap();
|
||||
assert_eq!(lenient.kind, CredentialKind::Password);
|
||||
assert_eq!(lenient.account, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_store_get_set_delete() {
|
||||
let store = InMemoryCredentialStore::new();
|
||||
|
||||
+13
-17
@@ -3,29 +3,25 @@
|
||||
//! tokenizer shared by the daemon- and client-side output scanners.
|
||||
//!
|
||||
//! These modules are framework-light and depend on neither `ui` nor `terminal`,
|
||||
//! so the dependency arrow always points *inward* to here. That keeps the door
|
||||
//! open to lifting `core` into a standalone crate later without untangling view
|
||||
//! code.
|
||||
//! so the dependency arrow always points *inward* to here.
|
||||
//!
|
||||
//! Most of it now lives one crate down, in `tty7-core`, so the headless
|
||||
//! `tty7-server` can share it — the modules re-exported below are that crate's,
|
||||
//! reachable under their original `crate::core::…` paths. What stays declared
|
||||
//! here is either gpui-shaped outright (`actions`, `update`) or the gpui half
|
||||
//! of a type whose data moved down (`config`, `session`, `window_state`).
|
||||
|
||||
// A glob, so every module `tty7-core` grows is reachable here for free. The
|
||||
// four `pub mod`s below deliberately shadow their glob-imported namesakes: each
|
||||
// is a thin layer that re-exports the core module's contents itself — gpui for
|
||||
// `config` / `session` / `window_state`, the OS keychain for `keychain`.
|
||||
pub use tty7_core::core::*;
|
||||
|
||||
pub mod actions;
|
||||
pub mod agent_hooks;
|
||||
pub mod agent_prompt;
|
||||
pub mod cli_agent;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
// so parts of it read as dead code until those merge.
|
||||
#[allow(dead_code)]
|
||||
pub mod keychain;
|
||||
pub mod osc;
|
||||
pub mod proc;
|
||||
pub mod session;
|
||||
pub mod shells;
|
||||
pub mod ssh_config;
|
||||
#[allow(dead_code)]
|
||||
pub mod ssh_profile;
|
||||
pub mod threads;
|
||||
pub mod update;
|
||||
pub mod window_state;
|
||||
pub mod worktree;
|
||||
|
||||
+327
-837
File diff suppressed because it is too large
Load Diff
+22
-78
@@ -1,36 +1,30 @@
|
||||
//! Persisted last-window geometry, stored at `window.json` in the config dir
|
||||
//! (alongside `config.json` / `session.json`). The quit hook in `ui::app`
|
||||
//! writes the window's final bounds here unconditionally; startup reads it
|
||||
//! back only when `Config::remember_window_size` is on, so toggling the
|
||||
//! setting off and on again still restores the most recent quit's geometry.
|
||||
//! Same durability contract as the other config-dir files: missing/malformed
|
||||
//! reads fall back to "nothing remembered", writes are atomic.
|
||||
//! The gpui-facing half of [`WindowState`].
|
||||
//!
|
||||
//! The struct itself, its `window.json` IO, and the "is this geometry sane"
|
||||
//! guard live in `tty7-core` — `session.json` embeds the geometry in each
|
||||
//! [`Workspace`](crate::core::session::Workspace), so it has to parse on a
|
||||
//! machine that never links gpui. What is left here is the only part that
|
||||
//! genuinely needs gpui: turning the four stored `f32`s into a
|
||||
//! [`Bounds<Pixels>`] and back.
|
||||
|
||||
use gpui::{Bounds, Pixels, point, px};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Don't restore a window smaller than this (logical px) — a corrupt or
|
||||
/// hand-edited file shouldn't reopen tty7 as a sliver.
|
||||
const MIN_SIZE: f32 = 200.0;
|
||||
pub use tty7_core::core::window_state::WindowState;
|
||||
|
||||
/// Last known window geometry, in gpui's global coordinate space (logical
|
||||
/// pixels; origins can be negative or beyond the primary display on
|
||||
/// multi-monitor setups). For a fullscreen window this records the *restore*
|
||||
/// bounds, so the next normal launch isn't screen-sized.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Conversions between the stored geometry and gpui's window bounds.
|
||||
///
|
||||
/// An extension trait rather than inherent methods because the type lives in
|
||||
/// `tty7-core`; bring it into scope and `WindowState::from_bounds(..)` /
|
||||
/// `state.bounds()` read exactly as they did before the crate split.
|
||||
pub trait WindowGeometry: Sized {
|
||||
/// Capture a window's current bounds for persisting.
|
||||
fn from_bounds(bounds: Bounds<Pixels>) -> Self;
|
||||
/// The bounds to reopen a window at.
|
||||
fn bounds(&self) -> Bounds<Pixels>;
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
crate::core::config::config_path("window.json")
|
||||
}
|
||||
|
||||
pub fn from_bounds(bounds: Bounds<Pixels>) -> Self {
|
||||
impl WindowGeometry for WindowState {
|
||||
fn from_bounds(bounds: Bounds<Pixels>) -> Self {
|
||||
Self {
|
||||
x: bounds.origin.x.into(),
|
||||
y: bounds.origin.y.into(),
|
||||
@@ -39,52 +33,12 @@ impl WindowState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> Bounds<Pixels> {
|
||||
fn bounds(&self) -> Bounds<Pixels> {
|
||||
Bounds {
|
||||
origin: point(px(self.x), px(self.y)),
|
||||
size: gpui::size(px(self.width), px(self.height)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the remembered geometry; `None` when nothing usable is on disk
|
||||
/// (never saved, unreadable, malformed, or degenerate values), in which
|
||||
/// case the caller falls back to the centered default.
|
||||
pub fn load() -> Option<Self> {
|
||||
let path = Self::path()?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let state: Self = serde_json::from_str(&text)
|
||||
.map_err(|e| log::warn!("failed to parse {}: {e}; ignoring", path.display()))
|
||||
.ok()?;
|
||||
state.is_usable().then_some(state)
|
||||
}
|
||||
|
||||
/// A geometry worth restoring: all values finite and the size at least
|
||||
/// [`MIN_SIZE`] each way.
|
||||
fn is_usable(&self) -> bool {
|
||||
[self.x, self.y, self.width, self.height]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
&& self.width >= MIN_SIZE
|
||||
&& self.height >= MIN_SIZE
|
||||
}
|
||||
|
||||
/// Persist the geometry; IO / serialization errors are logged and swallowed
|
||||
/// (worst case the next launch opens at the default size).
|
||||
pub fn save(&self) {
|
||||
let Some(path) = Self::path() else {
|
||||
return;
|
||||
};
|
||||
let json = match serde_json::to_string_pretty(self) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("failed to serialize window state: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) {
|
||||
log::warn!("failed to write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -101,14 +55,4 @@ mod tests {
|
||||
};
|
||||
assert_eq!(WindowState::from_bounds(state.bounds()), state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_degenerate_geometry() {
|
||||
let usable =
|
||||
|json: &str| serde_json::from_str::<WindowState>(json).is_ok_and(|s| s.is_usable());
|
||||
assert!(usable(r#"{"x":-120.5,"y":42,"width":1440,"height":900}"#));
|
||||
assert!(!usable(r#"{"x":0,"y":0,"width":50,"height":900}"#));
|
||||
assert!(!usable(r#"{"x":null,"y":0,"width":1440,"height":900}"#));
|
||||
assert!(!usable("not json"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The persistent terminal daemon, as the GUI sees it.
|
||||
//!
|
||||
//! The daemon itself — PTY ownership, replay rings, fan-out, the wire protocol,
|
||||
//! the native SSH engine — moved wholesale into `tty7-core` when the headless
|
||||
//! `tty7-server` needed to run exactly the same code on a machine with no
|
||||
//! display. Nothing about it was GUI-shaped to begin with (it has never
|
||||
//! referenced gpui, `terminal`, or `ui`), so the move was a relocation, not a
|
||||
//! rewrite.
|
||||
//!
|
||||
//! This re-export keeps the GUI's call sites reading `crate::daemon::protocol`,
|
||||
//! `crate::daemon::spawn::ensure_running`, and so on, exactly as before. The
|
||||
//! client-side terminal that talks the protocol is still
|
||||
//! `terminal::remote::RemoteTerminal`.
|
||||
|
||||
pub use tty7_core::daemon::*;
|
||||
+7
-2
@@ -303,11 +303,16 @@ fn main() {
|
||||
// panic — no message, no location. Record those to `crash.log` in the config
|
||||
// dir. Installed here, right after the config dir resolves, so both the GUI
|
||||
// and the daemon below are covered from their first line of real work.
|
||||
crate::core::crash::install(if std::env::args().any(|a| a == "--daemon") {
|
||||
let role = if std::env::args().any(|a| a == "--daemon") {
|
||||
"daemon"
|
||||
} else {
|
||||
"gui"
|
||||
});
|
||||
};
|
||||
crate::core::crash::install(role);
|
||||
// And the ordinary `log::` records, which otherwise go nowhere at all —
|
||||
// the daemon's stdio is `/dev/null` by the time it is detached. Off unless
|
||||
// `TTY7_LOG` asks for it; see `core::logfile`.
|
||||
crate::core::logfile::install(role);
|
||||
|
||||
// Daemon mode: when launched with `--daemon` we run the headless persistent
|
||||
// terminal server and never open a window. This is the backing process the GUI
|
||||
|
||||
@@ -1252,7 +1252,10 @@ impl TerminalElement {
|
||||
// Super off macOS, which the OS mostly swallows — and every
|
||||
// other terminal there opens links on Ctrl+click.
|
||||
let link_modifier = mods.secondary() || v.link_modifier_down();
|
||||
if link_modifier && button == MouseButton::Left && v.open_link_at(col, row, cx) {
|
||||
if link_modifier
|
||||
&& button == MouseButton::Left
|
||||
&& v.open_link_at(col, row, window, cx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Report to the app when in mouse-tracking mode (Shift forces
|
||||
|
||||
+24
-17
@@ -3,15 +3,18 @@
|
||||
//! (see [`crate::ui::diff_overlay`]) that covers the terminal when the user
|
||||
//! clicks a tab row's git line.
|
||||
//!
|
||||
//! Same discipline as [`git_status`](crate::terminal::git_status): plain
|
||||
//! shell-outs run on a background executor by the caller, read-only via
|
||||
//! `GIT_OPTIONAL_LOCKS=0` (through the shared [`git_status::git`] helper), and
|
||||
//! never trusted to be fast — the UI shows the previous snapshot (or a loading
|
||||
//! state) until a probe lands.
|
||||
//! Same discipline as [`git_status`](crate::terminal::git_status): every
|
||||
//! invocation goes through the shared [`git_status::git`] helper — so it runs
|
||||
//! on the pane's own [`Host`], read-only via `GIT_OPTIONAL_LOCKS=0` — on a
|
||||
//! background executor, and is never trusted to be fast; the UI shows the
|
||||
//! previous snapshot (or a loading state) until a probe lands. Asking the pane's
|
||||
//! host rather than this machine is also what makes the overlay work at all for
|
||||
//! a pane whose repository lives somewhere else.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::terminal::git_status;
|
||||
use crate::ui::host_ops::Host;
|
||||
|
||||
/// Cap on parsed diff lines per file. A generated lockfile or vendored blob
|
||||
/// can be tens of thousands of lines; past this the file's hunks stop and the
|
||||
@@ -106,28 +109,32 @@ pub struct DiffLine {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Probe the full diff snapshot for `cwd`, or `None` when it isn't inside a
|
||||
/// git work tree. Blocking (three `git` shell-outs) — call it on a background
|
||||
/// executor.
|
||||
pub fn probe(cwd: &Path) -> Option<DiffSnapshot> {
|
||||
if !cwd.exists() {
|
||||
return None;
|
||||
}
|
||||
/// Probe the full diff snapshot for `cwd` on `host`, or `None` when it isn't
|
||||
/// inside a git work tree. Blocking (three `git` invocations, three round trips
|
||||
/// on a remote host) — call it through `HostOps`, never on the UI thread.
|
||||
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
|
||||
// No `exists` pre-check: a vanished cwd fails the first invocation with
|
||||
// `NotFound`, which is the same `None` one round trip cheaper.
|
||||
// Doubles as the "is this a repo" gate, same as the status probe.
|
||||
let root = git_status::git(cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let root = git_status::git(host, cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
|
||||
let branch = git_status::branch_name(cwd)?;
|
||||
let branch = git_status::branch_name(host, cwd)?;
|
||||
// `-M` folds a delete+add pair back into one rename entry; `--no-ext-diff`
|
||||
// keeps a configured external diff tool from replacing the parseable
|
||||
// unified format. A failed diff (e.g. racing a concurrent git write) still
|
||||
// yields a snapshot — an empty file list with the branch — rather than
|
||||
// hiding the overlay; the next refresh fills it in.
|
||||
let files = git_status::git(cwd, &["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"])
|
||||
.map(|out| parse_unified(&out))
|
||||
.unwrap_or_default();
|
||||
let files = git_status::git(
|
||||
host,
|
||||
cwd,
|
||||
&["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"],
|
||||
)
|
||||
.map(|out| parse_unified(&out))
|
||||
.unwrap_or_default();
|
||||
// `--full-name` pins paths to the repo root regardless of which
|
||||
// subdirectory the pane sits in, matching the diff's path space.
|
||||
let untracked = git_status::git(
|
||||
host,
|
||||
cwd,
|
||||
&["ls-files", "--others", "--exclude-standard", "--full-name"],
|
||||
)
|
||||
|
||||
+186
-305
@@ -4,116 +4,32 @@
|
||||
//! count.
|
||||
//!
|
||||
//! Snapshots are shared through [`GitStatusCache`], a process-wide map keyed
|
||||
//! by work-tree root: every pane whose cwd resolves into the same repo reads
|
||||
//! the *same* entry, so ten tabs in one repo show one truth, refreshed by
|
||||
//! whichever pane probed last — not ten drifting copies refreshed on ten
|
||||
//! different schedules. Probes stay per-trigger (a pane's cwd change, command
|
||||
//! end, or agent-turn end — see [`crate::terminal::view`]) but are deduped
|
||||
//! in-flight, so simultaneous triggers from panes in the same directory cost
|
||||
//! one `git` shell-out, not one per pane.
|
||||
//! by machine *and* work-tree root: every pane whose cwd resolves into the same
|
||||
//! repo reads the *same* entry, so ten tabs in one repo show one truth,
|
||||
//! refreshed by whichever pane probed last — not ten drifting copies refreshed
|
||||
//! on ten different schedules. Probes stay per-trigger (a pane's cwd change,
|
||||
//! command end, or agent-turn end — see [`crate::terminal::view`]) but are
|
||||
//! deduped in-flight, so simultaneous triggers from panes in the same directory
|
||||
//! cost one `git` invocation, not one per pane.
|
||||
//!
|
||||
//! Deliberately shell-out simple: one `git` invocation per field, run on a
|
||||
//! background thread by the caller so the UI never blocks on a slow repo.
|
||||
//! Read-only — `GIT_OPTIONAL_LOCKS=0` keeps status polling from ever taking
|
||||
//! `index.lock` and fighting a real git command the user is running.
|
||||
//! **Machine is part of every key.** `/home/me/proj` is a real path on this
|
||||
//! laptop and on the box it is SSH'd into, and they are different repositories
|
||||
//! on different branches. A cache keyed by path alone would serve one's branch
|
||||
//! line for the other, so every table here is a [`ByHost`] and every entry
|
||||
//! point takes the [`HostId`] the cwd belongs to.
|
||||
//!
|
||||
//! The probe itself — [`probe`], [`branch_name`], and the [`git`] invocation
|
||||
//! every git read in tty7 funnels through — lives in `tty7-core`, because the
|
||||
//! remote server has to answer the same questions the same way. All three now
|
||||
//! take the [`Host`](crate::ui::host_ops::Host) to ask, which is what lets a
|
||||
//! pane on another machine report its own repository instead of reporting
|
||||
//! nothing. What stays here is the cache, which is a gpui `Global`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A repo's git snapshot: the branch it's on and how much the working tree has
|
||||
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
|
||||
/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files
|
||||
/// and untracked files don't contribute a line count.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GitStatus {
|
||||
/// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD
|
||||
/// is detached. Never empty.
|
||||
pub branch: String,
|
||||
/// Lines added across the working tree vs `HEAD`.
|
||||
pub added: u32,
|
||||
/// Lines removed across the working tree vs `HEAD`.
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// One raw probe result, before it's folded into the cache: which work tree
|
||||
/// `cwd` belongs to, plus the fields probed there. `counts` is `None` when the
|
||||
/// `git diff` invocation itself failed (e.g. it raced a concurrent git write) —
|
||||
/// distinct from a clean tree's `Some((0, 0))`, so the cache can keep the
|
||||
/// previous numbers instead of pretending the tree went clean.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RepoSnapshot {
|
||||
/// The work tree root (`git rev-parse --show-toplevel`) — the cache key
|
||||
/// every pane inside this work tree shares. For a linked worktree this is
|
||||
/// the worktree's own directory, not the main checkout's.
|
||||
pub root: PathBuf,
|
||||
/// The *repository* the work tree belongs to: the main checkout's root
|
||||
/// when `root` is a linked worktree, otherwise `root` itself. The
|
||||
/// sidebar's grouping key — every worktree of one repo shares it, while
|
||||
/// branch/diff state stays per work tree under `root`.
|
||||
pub home: PathBuf,
|
||||
pub branch: String,
|
||||
pub counts: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Probe the git snapshot for `cwd`, or `None` when it isn't inside a git work
|
||||
/// tree (or the path is gone). Blocking — call it on a background executor.
|
||||
pub fn probe(cwd: &Path) -> Option<RepoSnapshot> {
|
||||
if !cwd.exists() {
|
||||
return None;
|
||||
}
|
||||
// One `rev-parse` answers every path question at once: the work-tree root
|
||||
// (which doubles as the "is this a git repo" gate — it fails outside a
|
||||
// work tree) plus the git-dir/common-dir pair that tells a linked worktree
|
||||
// from a main checkout. Asking separately cost two process spawns per
|
||||
// probe, which mattered once probes stopped being rare: they now also fire
|
||||
// on window activation and on an agent's tool calls, across every pane.
|
||||
let paths = git(
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--show-toplevel",
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
)?;
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
// A git old enough to reject `--path-format` fails the whole invocation
|
||||
// above, so reaching here means the two dirs are present — but degrade to
|
||||
// "main checkout" rather than trusting that, same as the old code did.
|
||||
let home = repo_home(&root, lines.next(), lines.next());
|
||||
let branch = branch_name(cwd)?;
|
||||
Some(RepoSnapshot {
|
||||
home,
|
||||
root,
|
||||
branch,
|
||||
counts: diff_numstat(cwd),
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository "home" every checkout of one repo shares, from the work-tree
|
||||
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
|
||||
/// (its git dir differs from the common git dir) the main work tree's root —
|
||||
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
|
||||
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
|
||||
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
|
||||
/// the bare directory itself — still one shared key.
|
||||
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
|
||||
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
if git_dir == common {
|
||||
return root.to_path_buf();
|
||||
}
|
||||
let common = Path::new(common);
|
||||
match (common.file_name(), common.parent()) {
|
||||
(Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(),
|
||||
_ => common.to_path_buf(),
|
||||
}
|
||||
}
|
||||
pub use crate::core::git::{GitStatus, RepoSnapshot, branch_name, git, probe};
|
||||
use crate::ui::host_ops::{ByHost, HostId, InFlight};
|
||||
|
||||
/// The process-wide snapshot store (a gpui [`Global`](gpui::Global)): pane
|
||||
/// cwds grouped by work-tree root, one [`GitStatus`] per root. Views read
|
||||
@@ -128,22 +44,21 @@ fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> Pa
|
||||
#[derive(Default)]
|
||||
pub struct GitStatusCache {
|
||||
/// cwd → its work-tree root; `None` = probed and found not to be a repo.
|
||||
roots: HashMap<PathBuf, Option<PathBuf>>,
|
||||
roots: ByHost<PathBuf, Option<PathBuf>>,
|
||||
/// work-tree root → the repository home it belongs to (see
|
||||
/// [`RepoSnapshot::home`]). Identity for a plain checkout; the main
|
||||
/// root for a linked worktree, so the sidebar groups them together.
|
||||
homes: HashMap<PathBuf, PathBuf>,
|
||||
homes: ByHost<PathBuf, PathBuf>,
|
||||
/// root → the snapshot every pane in that tree shares.
|
||||
status: HashMap<PathBuf, GitStatus>,
|
||||
/// cwds with a probe currently in flight, so concurrent triggers fold
|
||||
/// into one shell-out.
|
||||
in_flight: HashSet<PathBuf>,
|
||||
/// In-flight cwds re-triggered meanwhile — reprobed once their flight
|
||||
/// lands, so the newest trigger's state is never skipped.
|
||||
dirty: HashSet<PathBuf>,
|
||||
status: ByHost<PathBuf, GitStatus>,
|
||||
/// Probes in flight, and which of those were re-triggered while flying —
|
||||
/// so concurrent triggers fold into one invocation and the newest
|
||||
/// trigger's state is still observed. Keyed by `(host, cwd)`: two machines
|
||||
/// at the same path are two independent probes.
|
||||
probes: InFlight<(HostId, PathBuf)>,
|
||||
/// When each cwd's last probe *landed*, for the throttle that opportunistic
|
||||
/// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)).
|
||||
last_probe: HashMap<PathBuf, Instant>,
|
||||
last_probe: ByHost<PathBuf, Instant>,
|
||||
}
|
||||
|
||||
impl gpui::Global for GitStatusCache {}
|
||||
@@ -152,9 +67,9 @@ impl GitStatusCache {
|
||||
/// The snapshot for a pane at `cwd`: resolved through its work-tree root,
|
||||
/// so every pane in the same repo answers identically. `None` before the
|
||||
/// first probe lands or when `cwd` isn't in a repo.
|
||||
pub fn status_for(&self, cwd: &Path) -> Option<GitStatus> {
|
||||
let root = self.roots.get(cwd)?.as_ref()?;
|
||||
self.status.get(root).cloned()
|
||||
pub fn status_for(&self, host: HostId, cwd: &Path) -> Option<GitStatus> {
|
||||
let root = self.roots.get(host, cwd)?.as_ref()?;
|
||||
self.status.get(host, root).cloned()
|
||||
}
|
||||
|
||||
/// What the cache *knows* about the repository `cwd` belongs to,
|
||||
@@ -165,11 +80,11 @@ impl GitStatusCache {
|
||||
/// `home` is the repository home, not the work-tree root — a linked
|
||||
/// worktree answers with the main checkout's root, so every worktree of
|
||||
/// one repo lands in one sidebar group.
|
||||
pub fn known_repo_for(&self, cwd: &Path) -> Option<Option<PathBuf>> {
|
||||
let root = self.roots.get(cwd)?;
|
||||
pub fn known_repo_for(&self, host: HostId, cwd: &Path) -> Option<Option<PathBuf>> {
|
||||
let root = self.roots.get(host, cwd)?;
|
||||
Some(root.as_ref().map(|root| {
|
||||
self.homes
|
||||
.get(root)
|
||||
.get(host, root)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| root.clone())
|
||||
}))
|
||||
@@ -178,13 +93,15 @@ impl GitStatusCache {
|
||||
/// Claim a probe for `cwd`. `false` means one is already in flight — the
|
||||
/// caller must *not* spawn another; the landed flight will reprobe once
|
||||
/// (the cwd is marked dirty) so this trigger's state still gets observed.
|
||||
pub fn begin_probe(&mut self, cwd: &Path) -> bool {
|
||||
if self.in_flight.contains(cwd) {
|
||||
self.dirty.insert(cwd.to_path_buf());
|
||||
false
|
||||
} else {
|
||||
self.in_flight.insert(cwd.to_path_buf());
|
||||
pub fn begin_probe(&mut self, host: HostId, cwd: &Path) -> bool {
|
||||
let key = (host, cwd.to_path_buf());
|
||||
if self.probes.begin(key.clone()) {
|
||||
true
|
||||
} else {
|
||||
// Already flying: mark it superseded so the landing asks for one
|
||||
// more run rather than dropping this trigger's state.
|
||||
self.probes.invalidate(&key);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,20 +123,25 @@ impl GitStatusCache {
|
||||
/// scattered over one repo's subdirectories would all claim in the same
|
||||
/// instant — each of them passing a throttle no probe had answered yet —
|
||||
/// and produce a dozen identical full-repo diffs.
|
||||
pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool {
|
||||
if self.in_flight.contains(cwd) {
|
||||
pub fn begin_probe_throttled(
|
||||
&mut self,
|
||||
host: HostId,
|
||||
cwd: &Path,
|
||||
min_interval: Duration,
|
||||
) -> bool {
|
||||
if self.probes.is_pending(&(host, cwd.to_path_buf())) {
|
||||
return false;
|
||||
}
|
||||
let key = self.throttle_key(cwd).to_path_buf();
|
||||
let key = self.throttle_key(host, cwd).to_path_buf();
|
||||
if self
|
||||
.last_probe
|
||||
.get(&key)
|
||||
.get(host, key.as_path())
|
||||
.is_some_and(|at| at.elapsed() < min_interval)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_probe.insert(key, Instant::now());
|
||||
self.in_flight.insert(cwd.to_path_buf());
|
||||
self.last_probe.insert(host, key, Instant::now());
|
||||
self.probes.begin((host, cwd.to_path_buf()));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -236,8 +158,8 @@ impl GitStatusCache {
|
||||
/// Before any probe has landed the root is simply unknown, so the first
|
||||
/// sweep over a repo still costs one probe per distinct cwd; every sweep
|
||||
/// after that collapses to one.
|
||||
fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path {
|
||||
match self.roots.get(cwd) {
|
||||
fn throttle_key<'a>(&'a self, host: HostId, cwd: &'a Path) -> &'a Path {
|
||||
match self.roots.get(host, cwd) {
|
||||
Some(Some(root)) => root,
|
||||
_ => cwd,
|
||||
}
|
||||
@@ -247,25 +169,34 @@ impl GitStatusCache {
|
||||
/// live repo keeps the root's previous counts (a transient `git` error is
|
||||
/// not "the tree went clean"). Returns whether the cwd was re-triggered
|
||||
/// while this probe flew — the caller should start one more probe.
|
||||
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
|
||||
self.in_flight.remove(cwd);
|
||||
pub fn finish_probe(
|
||||
&mut self,
|
||||
host: HostId,
|
||||
cwd: &Path,
|
||||
snapshot: Option<RepoSnapshot>,
|
||||
) -> bool {
|
||||
// `finish` both retires the claim and reports whether it survived: it
|
||||
// answers "still current", so the rerun this function promises is its
|
||||
// negation.
|
||||
let rerun = !self.probes.finish(&(host, cwd.to_path_buf()));
|
||||
// Re-stamp on landing so the gap is measured from fresh counts, and
|
||||
// under the root this probe just resolved — which is how a cwd first
|
||||
// learns to share its repo's clock (at claim time it had none).
|
||||
let key = match &snapshot {
|
||||
Some(snap) => snap.root.clone(),
|
||||
None => self.throttle_key(cwd).to_path_buf(),
|
||||
None => self.throttle_key(host, cwd).to_path_buf(),
|
||||
};
|
||||
self.last_probe.insert(key, Instant::now());
|
||||
self.last_probe.insert(host, key, Instant::now());
|
||||
match snapshot {
|
||||
Some(snap) => {
|
||||
let (added, removed) = snap.counts.unwrap_or_else(|| {
|
||||
self.status
|
||||
.get(&snap.root)
|
||||
.get(host, &snap.root)
|
||||
.map(|g| (g.added, g.removed))
|
||||
.unwrap_or((0, 0))
|
||||
});
|
||||
self.status.insert(
|
||||
host,
|
||||
snap.root.clone(),
|
||||
GitStatus {
|
||||
branch: snap.branch,
|
||||
@@ -273,107 +204,26 @@ impl GitStatusCache {
|
||||
removed,
|
||||
},
|
||||
);
|
||||
self.homes.insert(snap.root.clone(), snap.home);
|
||||
self.roots.insert(cwd.to_path_buf(), Some(snap.root));
|
||||
self.homes.insert(host, snap.root.clone(), snap.home);
|
||||
self.roots.insert(host, cwd.to_path_buf(), Some(snap.root));
|
||||
}
|
||||
// Not a repo (or the dir vanished). The root's entry stays for
|
||||
// other cwds that still live in it.
|
||||
None => {
|
||||
self.roots.insert(cwd.to_path_buf(), None);
|
||||
self.roots.insert(host, cwd.to_path_buf(), None);
|
||||
}
|
||||
}
|
||||
self.dirty.remove(cwd)
|
||||
rerun
|
||||
}
|
||||
}
|
||||
|
||||
/// The current branch name, or a short sha for a detached HEAD. Shared with
|
||||
/// [`git_diff`](crate::terminal::git_diff), which fronts its overlay with the
|
||||
/// same branch label the sidebar row shows.
|
||||
pub(crate) fn branch_name(cwd: &Path) -> Option<String> {
|
||||
// On a branch — even before the first commit — `symbolic-ref` names it.
|
||||
if let Some(out) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
|
||||
let name = out.trim();
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
// Detached HEAD (or a rebase/bisect): fall back to the short commit sha.
|
||||
let sha = git(cwd, &["rev-parse", "--short", "HEAD"])?;
|
||||
let sha = sha.trim();
|
||||
(!sha.is_empty()).then(|| sha.to_string())
|
||||
}
|
||||
|
||||
/// Sum added/removed lines across the working tree vs `HEAD` from
|
||||
/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing.
|
||||
/// `None` when the invocation itself failed — the caller keeps old counts.
|
||||
fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
|
||||
let out = git(cwd, &["diff", "--numstat", "HEAD"])?;
|
||||
let mut added = 0u32;
|
||||
let mut removed = 0u32;
|
||||
for line in out.lines() {
|
||||
let mut fields = line.split('\t');
|
||||
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
|
||||
added += n;
|
||||
}
|
||||
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
|
||||
removed += n;
|
||||
}
|
||||
}
|
||||
Some((added, removed))
|
||||
}
|
||||
|
||||
/// Run `git -C <cwd> <args>` and return stdout on success, `None` on a
|
||||
/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read
|
||||
/// truly read-only; stdin is nulled so a misconfigured git can't block on a
|
||||
/// prompt; `hide_console` keeps this GUI process from flashing a console window
|
||||
/// on Windows for every probe. Shared with [`git_diff`](crate::terminal::git_diff)
|
||||
/// so every git read in the app goes through the same lock-free, prompt-proof
|
||||
/// invocation.
|
||||
pub(crate) fn git(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(cwd)
|
||||
.args(args)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let out = crate::core::proc::hide_console(&mut cmd).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(out.stdout).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A tmp path that is not a git repo yields no snapshot (and never panics).
|
||||
#[test]
|
||||
fn non_repo_is_none() {
|
||||
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
assert_eq!(probe(&dir), None);
|
||||
}
|
||||
|
||||
/// A path that doesn't exist is `None`, not a panic.
|
||||
#[test]
|
||||
fn missing_path_is_none() {
|
||||
assert_eq!(probe(Path::new("/no/such/tty7/path/here")), None);
|
||||
}
|
||||
|
||||
/// This repo (the crate root is inside the tty7 work tree) reports a branch
|
||||
/// and a root, exercising the real `git` probe end-to-end.
|
||||
#[test]
|
||||
fn own_repo_has_a_branch_and_root() {
|
||||
let here = env!("CARGO_MANIFEST_DIR");
|
||||
if let Some(snap) = probe(Path::new(here)) {
|
||||
assert!(!snap.branch.is_empty());
|
||||
assert!(Path::new(here).starts_with(&snap.root));
|
||||
}
|
||||
// If the crate is built outside a work tree (e.g. a vendored tarball),
|
||||
// `None` is the correct answer and the assertions above are skipped.
|
||||
}
|
||||
/// This machine — the host every pre-existing case implicitly used, back
|
||||
/// when there was only one.
|
||||
const L: HostId = HostId::LOCAL;
|
||||
|
||||
fn snap(root: &str, branch: &str, counts: Option<(u32, u32)>) -> RepoSnapshot {
|
||||
RepoSnapshot {
|
||||
@@ -393,32 +243,93 @@ mod tests {
|
||||
counts: Some((0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two cwds landing in the same work tree share one entry: a probe from
|
||||
/// either updates what both read (the group-by-root contract).
|
||||
#[test]
|
||||
fn cwds_in_one_repo_share_a_snapshot() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (a, b) = (Path::new("/repo/sub/a"), Path::new("/repo"));
|
||||
cache.finish_probe(a, Some(snap("/repo", "main", Some((5, 2)))));
|
||||
cache.finish_probe(b, Some(snap("/repo", "main", Some((5, 2)))));
|
||||
cache.finish_probe(L, a, Some(snap("/repo", "main", Some((5, 2)))));
|
||||
cache.finish_probe(L, b, Some(snap("/repo", "main", Some((5, 2)))));
|
||||
// A later probe from `a` refreshes the numbers `b` reads too.
|
||||
cache.finish_probe(a, Some(snap("/repo", "main", Some((200, 42)))));
|
||||
cache.finish_probe(L, a, Some(snap("/repo", "main", Some((200, 42)))));
|
||||
for cwd in [a, b] {
|
||||
let got = cache.status_for(cwd).unwrap();
|
||||
let got = cache.status_for(L, cwd).unwrap();
|
||||
assert_eq!((got.added, got.removed), (200, 42), "cwd {cwd:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same absolute path on two machines is two repositories. `/src/app`
|
||||
/// exists on this laptop and on the box it is SSH'd into, on different
|
||||
/// branches with different diffs — and before the tables were keyed by
|
||||
/// host, whichever probed last would have overwritten the other's branch
|
||||
/// line. Dedup and the throttle are per host too: a probe flying for one
|
||||
/// machine must not make the other's trigger silently vanish.
|
||||
#[test]
|
||||
fn one_path_on_two_machines_is_two_entries() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let remote = HostId::from_connection_key("ssh-direct:me@box:22");
|
||||
let cwd = Path::new("/src/app");
|
||||
|
||||
cache.finish_probe(L, cwd, Some(snap("/src/app", "main", Some((1, 2)))));
|
||||
cache.finish_probe(
|
||||
remote,
|
||||
cwd,
|
||||
Some(snap("/src/app", "feat/x", Some((30, 40)))),
|
||||
);
|
||||
|
||||
let local = cache.status_for(L, cwd).unwrap();
|
||||
let there = cache.status_for(remote, cwd).unwrap();
|
||||
assert_eq!((local.branch.as_str(), local.added), ("main", 1));
|
||||
assert_eq!((there.branch.as_str(), there.added), ("feat/x", 30));
|
||||
|
||||
// The repo *home* — what the sidebar groups by — is resolved per host
|
||||
// too. Here the same path is a plain checkout on one machine and a
|
||||
// linked worktree of a different repository on the other; a shared
|
||||
// `homes` table would have handed one machine's answer to the other.
|
||||
cache.finish_probe(
|
||||
remote,
|
||||
cwd,
|
||||
Some(wt_snap("/src/app", "/src/main", "feat/x")),
|
||||
);
|
||||
assert_eq!(
|
||||
cache.known_repo_for(L, cwd),
|
||||
Some(Some(PathBuf::from("/src/app")))
|
||||
);
|
||||
assert_eq!(
|
||||
cache.known_repo_for(remote, cwd),
|
||||
Some(Some(PathBuf::from("/src/main")))
|
||||
);
|
||||
|
||||
// A probe in flight for one host leaves the other free to claim.
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
assert!(cache.begin_probe(remote, cwd));
|
||||
assert!(!cache.begin_probe(L, cwd), "same host, already flying");
|
||||
assert!(cache.finish_probe(L, cwd, None), "…so it asks for a rerun");
|
||||
assert!(!cache.finish_probe(remote, cwd, None), "the other did not");
|
||||
|
||||
// …and the throttle clock is the host's own: one machine's fresh probe
|
||||
// does not silence the other's.
|
||||
let gap = Duration::from_secs(60);
|
||||
assert!(!cache.begin_probe_throttled(L, cwd, gap), "just landed");
|
||||
assert!(
|
||||
!cache.begin_probe_throttled(remote, cwd, gap),
|
||||
"just landed"
|
||||
);
|
||||
let other = Path::new("/elsewhere");
|
||||
assert!(cache.begin_probe_throttled(L, other, gap));
|
||||
assert!(cache.begin_probe_throttled(remote, other, gap));
|
||||
}
|
||||
|
||||
/// A failed `git diff` (counts `None`) keeps the previous numbers rather
|
||||
/// than rendering the tree as suddenly clean; the branch still updates.
|
||||
#[test]
|
||||
fn failed_diff_keeps_previous_counts() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let cwd = Path::new("/repo");
|
||||
cache.finish_probe(cwd, Some(snap("/repo", "main", Some((200, 42)))));
|
||||
cache.finish_probe(cwd, Some(snap("/repo", "feat/x", None)));
|
||||
let got = cache.status_for(cwd).unwrap();
|
||||
cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((200, 42)))));
|
||||
cache.finish_probe(L, cwd, Some(snap("/repo", "feat/x", None)));
|
||||
let got = cache.status_for(L, cwd).unwrap();
|
||||
assert_eq!(got.branch, "feat/x");
|
||||
assert_eq!((got.added, got.removed), (200, 42));
|
||||
}
|
||||
@@ -429,12 +340,12 @@ mod tests {
|
||||
fn concurrent_triggers_fold_into_one_probe_then_rerun() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let cwd = Path::new("/repo");
|
||||
assert!(cache.begin_probe(cwd));
|
||||
assert!(!cache.begin_probe(cwd)); // deduped, marked dirty
|
||||
assert!(cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
assert!(!cache.begin_probe(L, cwd)); // deduped, marked dirty
|
||||
assert!(cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
// The rerun claims cleanly and lands with nothing pending.
|
||||
assert!(cache.begin_probe(cwd));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
}
|
||||
|
||||
/// A cwd that leaves the repo (dir deleted / not a work tree) stops
|
||||
@@ -443,11 +354,11 @@ mod tests {
|
||||
fn non_repo_cwd_clears_only_itself() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (a, b) = (Path::new("/repo/a"), Path::new("/repo/b"));
|
||||
cache.finish_probe(a, Some(snap("/repo", "main", Some((3, 1)))));
|
||||
cache.finish_probe(b, Some(snap("/repo", "main", Some((3, 1)))));
|
||||
cache.finish_probe(a, None);
|
||||
assert_eq!(cache.status_for(a), None);
|
||||
assert!(cache.status_for(b).is_some());
|
||||
cache.finish_probe(L, a, Some(snap("/repo", "main", Some((3, 1)))));
|
||||
cache.finish_probe(L, b, Some(snap("/repo", "main", Some((3, 1)))));
|
||||
cache.finish_probe(L, a, None);
|
||||
assert_eq!(cache.status_for(L, a), None);
|
||||
assert!(cache.status_for(L, b).is_some());
|
||||
}
|
||||
|
||||
/// The three-valued `known_repo_for` the sidebar's repo grouping reads:
|
||||
@@ -462,18 +373,18 @@ mod tests {
|
||||
Path::new("/tmp/x"),
|
||||
Path::new("/never"),
|
||||
);
|
||||
cache.finish_probe(repo, Some(snap("/repo", "main", Some((1, 0)))));
|
||||
cache.finish_probe(plain, None);
|
||||
cache.finish_probe(L, repo, Some(snap("/repo", "main", Some((1, 0)))));
|
||||
cache.finish_probe(L, plain, None);
|
||||
|
||||
// Inside a work tree: the resolved repo home, wrapped twice.
|
||||
assert_eq!(
|
||||
cache.known_repo_for(repo),
|
||||
cache.known_repo_for(L, repo),
|
||||
Some(Some(PathBuf::from("/repo")))
|
||||
);
|
||||
// Probed and confirmed outside any repo: a definite "not a repo".
|
||||
assert_eq!(cache.known_repo_for(plain), Some(None));
|
||||
assert_eq!(cache.known_repo_for(L, plain), Some(None));
|
||||
// Never probed: no answer yet — the caller keeps its sticky key.
|
||||
assert_eq!(cache.known_repo_for(unseen), None);
|
||||
assert_eq!(cache.known_repo_for(L, unseen), None);
|
||||
}
|
||||
|
||||
/// Linked worktrees of one repository share a *group* (`known_repo_for`
|
||||
@@ -483,52 +394,22 @@ mod tests {
|
||||
fn worktrees_share_a_repo_but_not_a_status() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (main, wt) = (Path::new("/repo"), Path::new("/repo/.wt/feat"));
|
||||
cache.finish_probe(main, Some(wt_snap("/repo", "/repo", "main")));
|
||||
cache.finish_probe(wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x")));
|
||||
cache.finish_probe(L, main, Some(wt_snap("/repo", "/repo", "main")));
|
||||
cache.finish_probe(L, wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x")));
|
||||
|
||||
// One sidebar group…
|
||||
assert_eq!(
|
||||
cache.known_repo_for(main),
|
||||
cache.known_repo_for(L, main),
|
||||
Some(Some(PathBuf::from("/repo")))
|
||||
);
|
||||
assert_eq!(
|
||||
cache.known_repo_for(L, wt),
|
||||
Some(Some(PathBuf::from("/repo")))
|
||||
);
|
||||
assert_eq!(cache.known_repo_for(wt), Some(Some(PathBuf::from("/repo"))));
|
||||
// …two independent branch lines.
|
||||
assert_eq!(cache.status_for(main).unwrap().branch, "main");
|
||||
assert_eq!(cache.status_for(wt).unwrap().branch, "feat/x");
|
||||
assert_eq!(cache.status_for(L, main).unwrap().branch, "main");
|
||||
assert_eq!(cache.status_for(L, wt).unwrap().branch, "feat/x");
|
||||
}
|
||||
|
||||
/// The four shapes `repo_home` has to tell apart, straight from the
|
||||
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
|
||||
#[test]
|
||||
fn repo_home_resolves_worktree_layouts() {
|
||||
let root = Path::new("/repo/.wt/feat");
|
||||
|
||||
// A main checkout: the two dirs agree, so the work tree is its own home.
|
||||
assert_eq!(
|
||||
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A linked worktree: the common dir is the main checkout's `.git`, so
|
||||
// the home is that `.git`'s parent — the main work tree.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A bare repo with worktrees hanging off it: no `.git` component to
|
||||
// strip, so the bare dir itself is the shared key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
|
||||
PathBuf::from("/bare.git")
|
||||
);
|
||||
// A git too old (or too odd) to answer both: degrade to the work tree
|
||||
// rather than guessing a grouping key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git"), None),
|
||||
root.to_path_buf()
|
||||
);
|
||||
assert_eq!(repo_home(root, None, None), root.to_path_buf());
|
||||
}
|
||||
|
||||
/// The opportunistic path declines where the edge path queues: an in-flight
|
||||
/// probe drops the trigger (and leaves nothing dirty, so no rerun), and a
|
||||
/// probe that just landed rate-limits the next one.
|
||||
@@ -538,19 +419,19 @@ mod tests {
|
||||
let cwd = Path::new("/repo");
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
assert!(cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(cache.begin_probe_throttled(L, cwd, gap));
|
||||
// In flight: declined, and unlike `begin_probe` it doesn't mark dirty —
|
||||
// the landing reports "nothing pending" rather than asking for a rerun.
|
||||
assert!(!cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(!cache.begin_probe_throttled(L, cwd, gap));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
|
||||
// Landed just now: still inside the gap, so the next trigger is dropped.
|
||||
assert!(!cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.begin_probe_throttled(L, cwd, gap));
|
||||
// …but a zero gap always lets one through, and edge triggers never
|
||||
// consult the throttle at all.
|
||||
assert!(cache.begin_probe_throttled(cwd, Duration::ZERO));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(cwd));
|
||||
assert!(cache.begin_probe_throttled(L, cwd, Duration::ZERO));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
}
|
||||
|
||||
/// The throttle is per repo, not per cwd: panes sitting in different
|
||||
@@ -569,24 +450,24 @@ mod tests {
|
||||
|
||||
// Nothing known yet, so each cwd is its own key and each gets a probe.
|
||||
for cwd in [top, src, docs] {
|
||||
assert!(cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1))))));
|
||||
assert!(cache.begin_probe_throttled(L, cwd, gap));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((3, 1))))));
|
||||
}
|
||||
|
||||
// Now all three resolve to `/repo`, so the next sweep collapses: the
|
||||
// first pane to ask spends the probe and the rest ride on it.
|
||||
assert!(!cache.begin_probe_throttled(top, gap));
|
||||
assert!(!cache.begin_probe_throttled(src, gap));
|
||||
assert!(!cache.begin_probe_throttled(L, top, gap));
|
||||
assert!(!cache.begin_probe_throttled(L, src, gap));
|
||||
|
||||
// …and the claim itself is what stops the stampede — with the clock
|
||||
// wound back far enough to let one through, the *others* still decline
|
||||
// while it is in flight, even though nothing has landed yet.
|
||||
assert!(cache.begin_probe_throttled(docs, Duration::ZERO));
|
||||
assert!(!cache.begin_probe_throttled(top, gap));
|
||||
assert!(!cache.begin_probe_throttled(src, gap));
|
||||
assert!(cache.begin_probe_throttled(L, docs, Duration::ZERO));
|
||||
assert!(!cache.begin_probe_throttled(L, top, gap));
|
||||
assert!(!cache.begin_probe_throttled(L, src, gap));
|
||||
|
||||
// A pane elsewhere is untouched by any of it.
|
||||
let other = Path::new("/other");
|
||||
assert!(cache.begin_probe_throttled(other, gap));
|
||||
assert!(cache.begin_probe_throttled(L, other, gap));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ pub mod input;
|
||||
mod loopback;
|
||||
pub(crate) mod marks;
|
||||
pub mod palette;
|
||||
pub(crate) mod pane_liveness;
|
||||
mod remote;
|
||||
mod reverse_search;
|
||||
pub mod search;
|
||||
@@ -39,6 +40,6 @@ mod smart_select;
|
||||
mod typeahead;
|
||||
pub mod view;
|
||||
|
||||
pub use remote::RemoteTerminal;
|
||||
pub(crate) use remote::notify_desktop;
|
||||
pub use remote::{PaneRoute, PaneWorkspace, RemoteTerminal};
|
||||
pub use size::TermSize;
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Which of a workspace's saved panes are still running — asked of **the
|
||||
//! machine that workspace lives on**, cached per machine, probed off the UI
|
||||
//! thread.
|
||||
//!
|
||||
//! # The bug this exists to close
|
||||
//!
|
||||
//! A `pane_id` is minted by one daemon and means nothing to any other. Two
|
||||
//! machines hand out `1`, `2`, `3` in the same order, so a remote workspace's
|
||||
//! saved ids overlap this computer's almost perfectly. Every liveness question
|
||||
//! that skipped the route therefore had *two* wrong answers available: the
|
||||
//! benign one (the remote's ids are absent here, so its sessions read as
|
||||
//! stopped) and the misleading one — the id happens to name a live *local*
|
||||
//! pane, and a workspace on a box that has been off for a week lights up green
|
||||
//! because somebody's shell on this laptop holds the number. The title-bar
|
||||
//! workspace menu was doing exactly the latter.
|
||||
//!
|
||||
//! Routing alone does not fix a *cross-workspace* view. The picker and the
|
||||
//! workspace menu list several workspaces at once, on several machines at once,
|
||||
//! so there is no single route to send: it takes one query per machine, and
|
||||
//! those queries cannot be waited on in turn from `render`.
|
||||
//!
|
||||
//! # Three states, not two
|
||||
//!
|
||||
//! | State | Means | Drawn as |
|
||||
//! |---|---|---|
|
||||
//! | [`Liveness::Alive`] | the machine answered, and it still has one of these panes | green corner dot |
|
||||
//! | [`Liveness::Stopped`] | the machine answered, and none of them are left | no dot |
|
||||
//! | [`Liveness::Unknown`] | we could not ask | muted corner dot |
|
||||
//!
|
||||
//! `Unknown` is the state remote workspaces made necessary. A failed query to
|
||||
//! another machine is not evidence that anything died — the sessions are very
|
||||
//! probably fine and the *link* is what broke — and rendering it as "stopped"
|
||||
//! would tell the user their work is gone every time the network blinks.
|
||||
//!
|
||||
//! **`Unknown` is never shown for this machine.** A local `List` travels a unix
|
||||
//! socket to a daemon whose absence is itself the answer: no daemon, no live
|
||||
//! panes. So a local host with no cached answer reads `Stopped`, which is what
|
||||
//! this page has always drawn — the async cache changes remote behaviour and
|
||||
//! leaves local pixels alone.
|
||||
//!
|
||||
//! # How it is filled
|
||||
//!
|
||||
//! [`sweep`] is called from the render paths that show liveness. It never
|
||||
//! blocks: it looks at the workspace list, and for each machine whose answer is
|
||||
//! missing or past its TTL it starts one background query. All of them fly at
|
||||
//! once — N machines cost one round trip, not N in a row — and [`InFlight`]
|
||||
//! keeps a frame that re-asks before the answer lands from starting a second.
|
||||
//! Landing goes through `update_global`, so the `observe_global` hook in
|
||||
//! [`crate::ui::app`] repaints whatever is on screen.
|
||||
//!
|
||||
//! A machine this process has no connection to is **not** probed: asking would
|
||||
//! mean dialling SSH, and a liveness dot is not a reason to open a connection
|
||||
//! (or raise a passphrase prompt). It stays `Unknown`, which is the truth.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{App, AppContext as _, BorrowAppContext as _};
|
||||
|
||||
use crate::core::session::{Workspace, WorkspaceId, WorkspaceStore};
|
||||
use crate::terminal::{PaneRoute, RemoteTerminal};
|
||||
use crate::ui::host_ops::{HostId, InFlight};
|
||||
|
||||
/// How long this machine's answer stays fresh. The value the home picker's
|
||||
/// blocking cache used before any of this was routed, kept so a local
|
||||
/// workspace's dot updates on exactly the cadence it always did.
|
||||
const LOCAL_TTL: Duration = Duration::from_millis(2_000);
|
||||
|
||||
/// How long another machine's answer stays fresh. Longer than the local one
|
||||
/// because the query is a routed round trip rather than a unix socket, and a
|
||||
/// dot is not worth a heartbeat.
|
||||
const REMOTE_TTL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long to sit on a *failed* answer before asking again.
|
||||
///
|
||||
/// Shorter than the success TTL, which looks backwards until you notice the two
|
||||
/// are decaying different things. A landed answer is a fact, and facts about
|
||||
/// which shells are running go stale slowly. A failure is the absence of a fact:
|
||||
/// it carries nothing, it is the state the user most wants corrected, and the
|
||||
/// thing that would correct it — the link coming back — is exactly what this
|
||||
/// interval decides how fast we notice.
|
||||
const UNREACHABLE_TTL: Duration = Duration::from_secs(6);
|
||||
|
||||
/// How often [`sweep`] is allowed to walk the workspace list.
|
||||
///
|
||||
/// The sweep itself is called from `render`, which on a 120Hz display is 120
|
||||
/// times a second; the walk builds a `pane_ids()` vector and a connection key
|
||||
/// per workspace, which is not free enough to do that often. Everything past
|
||||
/// this gate is idempotent, so the only cost of the gate is that a probe may
|
||||
/// start up to a quarter-second late.
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
thread_local! {
|
||||
/// When [`sweep`] last walked the list.
|
||||
///
|
||||
/// Deliberately *not* a field of [`PaneLivenessCache`]: reaching a global
|
||||
/// mutably notifies its observers, the app repaints on that notification,
|
||||
/// and the repaint sweeps again — a stamp stored in the global would spin
|
||||
/// the render loop at full speed forever. It is also honestly thread-local
|
||||
/// state, since only the UI thread ever sweeps.
|
||||
static LAST_SWEEP: Cell<Option<Instant>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// What is known about a workspace's panes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Liveness {
|
||||
/// The machine answered and at least one of the workspace's panes is still
|
||||
/// running: reopening it reattaches to live shells.
|
||||
Alive,
|
||||
/// The machine answered and none of them are: the saved layout is all that
|
||||
/// is left, and reopening spawns fresh.
|
||||
Stopped,
|
||||
/// The machine could not be asked. Not the same as `Stopped` — see the
|
||||
/// module docs.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One machine's last landed answer.
|
||||
struct Answer {
|
||||
/// When it landed, for the TTL.
|
||||
at: Instant,
|
||||
/// The live pane ids the daemon reported, or `None` when the query failed.
|
||||
alive: Option<HashSet<u64>>,
|
||||
}
|
||||
|
||||
impl Answer {
|
||||
/// Whether this answer may still be used without re-asking.
|
||||
fn fresh(&self, host: HostId) -> bool {
|
||||
let ttl = match (&self.alive, host.is_local()) {
|
||||
(None, _) => UNREACHABLE_TTL,
|
||||
(Some(_), true) => LOCAL_TTL,
|
||||
(Some(_), false) => REMOTE_TTL,
|
||||
};
|
||||
self.at.elapsed() < ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide liveness store (a gpui [`Global`](gpui::Global)), keyed by
|
||||
/// machine.
|
||||
///
|
||||
/// Per **machine**, not per workspace: two workspaces on one box share a pane
|
||||
/// registry, so they share one query. That is the same granularity
|
||||
/// [`HostId`] already has everywhere else in the client.
|
||||
#[derive(Default)]
|
||||
pub struct PaneLivenessCache {
|
||||
answers: HashMap<HostId, Answer>,
|
||||
/// Queries out, so a render that re-asks before one lands does not start a
|
||||
/// second. There is nothing to supersede a liveness answer with, so only
|
||||
/// the in-flight half of [`InFlight`] is used.
|
||||
probes: InFlight<HostId>,
|
||||
}
|
||||
|
||||
impl gpui::Global for PaneLivenessCache {}
|
||||
|
||||
impl PaneLivenessCache {
|
||||
/// Whether any of `pane_ids` is still running on `host`.
|
||||
///
|
||||
/// `pane_ids` must be the ids **that machine** minted — i.e. the ids of a
|
||||
/// workspace whose `host_id()` is `host`. Mixing them is the bug the whole
|
||||
/// module exists to prevent, and keying by host is how it is prevented:
|
||||
/// there is no way to spell the question without naming the machine.
|
||||
pub fn liveness(&self, host: HostId, pane_ids: &[u64]) -> Liveness {
|
||||
// Claims nothing, so nothing about it is in question — not even on a
|
||||
// machine we cannot reach. Answered before the cache is consulted so an
|
||||
// empty workspace never draws the "unknown" dot while it waits for an
|
||||
// answer that could not change it.
|
||||
if pane_ids.is_empty() {
|
||||
return Liveness::Stopped;
|
||||
}
|
||||
match self.alive_set(host) {
|
||||
Some(alive) => {
|
||||
if pane_ids.iter().any(|id| alive.contains(id)) {
|
||||
Liveness::Alive
|
||||
} else {
|
||||
Liveness::Stopped
|
||||
}
|
||||
}
|
||||
// Never asked, or asked and refused. On this machine that is not a
|
||||
// mystery — an unreachable local daemon is a daemon with no panes
|
||||
// in it — so local resolves to `Stopped` and keeps the pre-remote
|
||||
// rendering exactly as it was.
|
||||
None if host.is_local() => Liveness::Stopped,
|
||||
None => Liveness::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The live ids `host` last reported, or `None` when the last word from it
|
||||
/// was a failure (or there has been no word at all).
|
||||
fn alive_set(&self, host: HostId) -> Option<&HashSet<u64>> {
|
||||
self.answers.get(&host)?.alive.as_ref()
|
||||
}
|
||||
|
||||
/// Whether a probe for `host` is worth starting: nothing fresh cached, and
|
||||
/// nothing already in flight.
|
||||
pub fn needs_probe(&self, host: HostId) -> bool {
|
||||
!self.probes.is_pending(&host)
|
||||
&& !self
|
||||
.answers
|
||||
.get(&host)
|
||||
.is_some_and(|answer| answer.fresh(host))
|
||||
}
|
||||
|
||||
/// Claim the probe for `host`. `false` when someone else already has it.
|
||||
pub fn begin_probe(&mut self, host: HostId) -> bool {
|
||||
self.probes.begin(host)
|
||||
}
|
||||
|
||||
/// Fold a landed probe in: `Some(ids)` when the daemon answered, `None`
|
||||
/// when it could not be reached.
|
||||
pub fn finish_probe(&mut self, host: HostId, alive: Option<HashSet<u64>>) {
|
||||
self.probes.finish(&host);
|
||||
self.answers.insert(
|
||||
host,
|
||||
Answer {
|
||||
at: Instant::now(),
|
||||
alive,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Forget what `host` said, so the next sweep asks again.
|
||||
///
|
||||
/// For the moments the app itself made the answer wrong — stopping a
|
||||
/// workspace kills panes the cache still lists — where waiting out the TTL
|
||||
/// would leave a green dot on a workspace the user just shut down.
|
||||
pub fn invalidate(&mut self, host: HostId) {
|
||||
self.answers.remove(&host);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PaneLivenessCache::liveness`] for a whole workspace, read-only.
|
||||
///
|
||||
/// The one call the render sites make. It cannot ask the wrong machine: the
|
||||
/// host and the ids both come off the same [`Workspace`].
|
||||
pub fn liveness_of(cx: &App, workspace: &Workspace) -> Liveness {
|
||||
let host = workspace.host_id();
|
||||
let ids = workspace.pane_ids();
|
||||
match cx.try_global::<PaneLivenessCache>() {
|
||||
Some(cache) => cache.liveness(host, &ids),
|
||||
// Before the app has installed the global. Asked of an empty cache
|
||||
// rather than answered here, so there is exactly one place that decides
|
||||
// what "nothing known yet" looks like and the first frame draws what
|
||||
// every later one will.
|
||||
None => PaneLivenessCache::default().liveness(host, &ids),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start whatever liveness queries the workspace list is missing.
|
||||
///
|
||||
/// Safe to call from `render`: it reads the cache, may start background work,
|
||||
/// and never blocks or waits. Rate-limited by [`SWEEP_INTERVAL`], deduplicated
|
||||
/// per machine by [`InFlight`], and gated per machine by the TTL — so a picker
|
||||
/// sitting open does not turn into a query loop.
|
||||
pub fn sweep(cx: &mut App) {
|
||||
let now = Instant::now();
|
||||
if LAST_SWEEP.get().is_some_and(|at| now < at + SWEEP_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
LAST_SWEEP.set(Some(now));
|
||||
|
||||
// One workspace per machine is enough to build that machine's route, and
|
||||
// only workspaces that claim panes have anything to ask about. Collected
|
||||
// first so the borrow of the store is released before the probes, which
|
||||
// need `cx` mutably.
|
||||
let mut targets: Vec<(HostId, WorkspaceId)> = Vec::new();
|
||||
for w in &WorkspaceStore::all(cx).workspaces {
|
||||
let host = w.host_id();
|
||||
if targets.iter().any(|(seen, _)| *seen == host) {
|
||||
continue;
|
||||
}
|
||||
if w.pane_ids().is_empty() {
|
||||
continue;
|
||||
}
|
||||
targets.push((host, w.id));
|
||||
}
|
||||
for (host, workspace) in targets {
|
||||
probe_host(cx, host, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask one machine, in the background.
|
||||
///
|
||||
/// `workspace` is only used to build the route; the answer is stored against
|
||||
/// the machine, and every workspace on it reads the same one.
|
||||
fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) {
|
||||
if !cx
|
||||
.try_global::<PaneLivenessCache>()
|
||||
.is_some_and(|cache| cache.needs_probe(host))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Never dial for a dot. A routed query to a machine this process is not
|
||||
// connected to would have the daemon open an SSH session — with whatever
|
||||
// passphrase prompt and multi-second handshake that entails — because a
|
||||
// picker row was on screen. Unconnected stays `Unknown`, which is exactly
|
||||
// what it is.
|
||||
//
|
||||
// Recorded as a landed failure rather than returned from: a bare `return`
|
||||
// would leave `needs_probe` true, so the next frame would re-decide this,
|
||||
// and `RemoteConnections::get` reaches its global mutably — which notifies,
|
||||
// which repaints, which sweeps. Storing the answer puts the decision behind
|
||||
// the same TTL as every other one.
|
||||
if !host.is_local() && crate::ui::remote_connect::RemoteConnections::get(cx, host).is_none() {
|
||||
cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, None));
|
||||
return;
|
||||
}
|
||||
let route = crate::ui::remote_workspace::pane_route_for(cx, workspace);
|
||||
// `global_mut` notifies, so the claim is taken last: everything above can
|
||||
// decline without costing a repaint.
|
||||
if !cx.global_mut::<PaneLivenessCache>().begin_probe(host) {
|
||||
return;
|
||||
}
|
||||
cx.spawn(async move |cx| {
|
||||
let alive = cx.background_spawn(async move { query(&route) }).await;
|
||||
// Landed through `update_global`, which is what wakes the
|
||||
// `observe_global` hook that repaints the picker and the title bar.
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, alive));
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The blocking half: one `List` down `route`, reduced to the ids that are
|
||||
/// still running. `None` is "could not ask", which is the distinction the whole
|
||||
/// three-state rendering rests on.
|
||||
fn query(route: &PaneRoute) -> Option<HashSet<u64>> {
|
||||
match RemoteTerminal::try_list_panes_on(route) {
|
||||
Ok(panes) => Some(
|
||||
panes
|
||||
.into_iter()
|
||||
.filter(|p| p.alive)
|
||||
.map(|p| p.pane_id)
|
||||
.collect(),
|
||||
),
|
||||
Err(e) => {
|
||||
log::debug!("pane liveness query failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A remote machine, and a second one, so "keyed by host" can be tested
|
||||
/// rather than asserted.
|
||||
fn box_a() -> HostId {
|
||||
HostId::from_connection_key("ssh-direct:me@a:22")
|
||||
}
|
||||
fn box_b() -> HostId {
|
||||
HostId::from_connection_key("ssh-direct:me@b:22")
|
||||
}
|
||||
|
||||
/// The three states, each from the input that produces it.
|
||||
#[test]
|
||||
fn the_three_states_come_from_three_different_situations() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
let host = box_a();
|
||||
|
||||
// Never asked: not "stopped" — unknown.
|
||||
assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Unknown);
|
||||
|
||||
// Asked, and the machine listed one of them.
|
||||
cache.finish_probe(host, Some(HashSet::from([2, 9])));
|
||||
assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Alive);
|
||||
|
||||
// Asked, and none of this workspace's panes are in the answer.
|
||||
assert_eq!(cache.liveness(host, &[1, 3]), Liveness::Stopped);
|
||||
|
||||
// Asked and could not be reached: back to unknown, *not* stopped —
|
||||
// the panes are very probably still running over there.
|
||||
cache.finish_probe(host, None);
|
||||
assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Unknown);
|
||||
}
|
||||
|
||||
/// The bug. Two machines mint the same small pane ids, and a workspace on
|
||||
/// one must never be lit up by the other's registry.
|
||||
#[test]
|
||||
fn one_machines_answer_never_speaks_for_another() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
// The local daemon is running panes 1 and 2 — the ids a fresh daemon
|
||||
// on any machine hands out first.
|
||||
cache.finish_probe(HostId::LOCAL, Some(HashSet::from([1, 2])));
|
||||
// The box has been off for a week: nothing of its own is claimed here.
|
||||
assert_eq!(cache.liveness(box_a(), &[1, 2]), Liveness::Unknown);
|
||||
// And a *third* machine's answer does not leak into the second's.
|
||||
cache.finish_probe(box_b(), Some(HashSet::from([1, 2])));
|
||||
assert_eq!(cache.liveness(box_a(), &[1, 2]), Liveness::Unknown);
|
||||
assert_eq!(cache.liveness(box_b(), &[1, 2]), Liveness::Alive);
|
||||
// Local still reads exactly as it always did.
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[1, 2]), Liveness::Alive);
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[7]), Liveness::Stopped);
|
||||
}
|
||||
|
||||
/// This machine never shows "unknown": an unreachable local daemon is a
|
||||
/// daemon with nothing running in it, and the picker drew that as a plain
|
||||
/// badge long before any of this was routed.
|
||||
#[test]
|
||||
fn local_never_renders_as_unknown() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
// Nothing asked yet — the state every first frame is in.
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[1]), Liveness::Stopped);
|
||||
// Asked, and no daemon answered.
|
||||
cache.finish_probe(HostId::LOCAL, None);
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[1]), Liveness::Stopped);
|
||||
}
|
||||
|
||||
/// A workspace that claims no panes is stopped on any machine — there is
|
||||
/// nothing for an answer to contain, so it is not "unknown" either, even on
|
||||
/// a machine that was never asked. Caught in the GUI: an empty remote
|
||||
/// workspace sat in the picker wearing the muted dot, which reads as "we
|
||||
/// could not check" about a workspace there is nothing to check.
|
||||
#[test]
|
||||
fn a_workspace_with_no_claimed_panes_is_never_alive() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
assert_eq!(cache.liveness(box_a(), &[]), Liveness::Stopped);
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[]), Liveness::Stopped);
|
||||
cache.finish_probe(box_a(), Some(HashSet::from([1, 2, 3])));
|
||||
assert_eq!(cache.liveness(box_a(), &[]), Liveness::Stopped);
|
||||
cache.finish_probe(box_a(), None);
|
||||
assert_eq!(cache.liveness(box_a(), &[]), Liveness::Stopped);
|
||||
}
|
||||
|
||||
/// One query per machine in flight, and a fresh answer stops the asking —
|
||||
/// this is what keeps a picker on screen from becoming a query loop.
|
||||
#[test]
|
||||
fn probes_are_deduplicated_and_then_throttled_by_the_ttl() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
let host = box_a();
|
||||
|
||||
assert!(cache.needs_probe(host), "nothing cached: ask");
|
||||
assert!(cache.begin_probe(host));
|
||||
assert!(!cache.needs_probe(host), "one is already out");
|
||||
assert!(!cache.begin_probe(host), "and it cannot be claimed twice");
|
||||
|
||||
cache.finish_probe(host, Some(HashSet::from([1])));
|
||||
assert!(!cache.needs_probe(host), "the answer is fresh");
|
||||
|
||||
// A failure is cached too, or an unreachable machine would be retried
|
||||
// on every frame — each retry a connect timeout on a background task.
|
||||
cache.finish_probe(host, None);
|
||||
assert!(!cache.needs_probe(host));
|
||||
|
||||
// Invalidation is the way back to asking, for the moments the app
|
||||
// itself made the answer wrong.
|
||||
cache.invalidate(host);
|
||||
assert!(cache.needs_probe(host));
|
||||
}
|
||||
|
||||
/// Each machine's freshness is its own: a fresh local answer must not stop
|
||||
/// the sweep from asking the box.
|
||||
#[test]
|
||||
fn freshness_is_per_machine() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
cache.finish_probe(HostId::LOCAL, Some(HashSet::new()));
|
||||
assert!(!cache.needs_probe(HostId::LOCAL));
|
||||
assert!(cache.needs_probe(box_a()));
|
||||
}
|
||||
|
||||
/// The TTLs are ordered the way the comments claim: a local socket may be
|
||||
/// re-asked far sooner than a routed round trip, and a failure — which is
|
||||
/// not a fact and is the state the user most wants corrected — is retried
|
||||
/// sooner than a success is refreshed.
|
||||
#[test]
|
||||
fn ttls_are_ordered_by_what_the_query_costs() {
|
||||
assert!(LOCAL_TTL < UNREACHABLE_TTL);
|
||||
assert!(UNREACHABLE_TTL < REMOTE_TTL);
|
||||
assert!(SWEEP_INTERVAL < LOCAL_TTL);
|
||||
}
|
||||
}
|
||||
+640
-19
@@ -44,7 +44,8 @@ use crate::daemon::protocol::{
|
||||
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
|
||||
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest,
|
||||
ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, SftpEntry, SftpJobProgress, SftpOp,
|
||||
SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
|
||||
SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize, WorkspaceOp,
|
||||
WorkspaceRequest,
|
||||
};
|
||||
use crate::daemon::transport::{self, Stream};
|
||||
|
||||
@@ -136,6 +137,159 @@ struct ReaderSignals {
|
||||
marks: crate::terminal::marks::Marks,
|
||||
}
|
||||
|
||||
/// The remote workspace a pane belongs to, and how the local daemon reaches its
|
||||
/// machine (design §15).
|
||||
///
|
||||
/// 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
|
||||
/// instead, and it is the input to every workspace-scoped request: the id says
|
||||
/// what a forward is *owned* by, the spec says which connection it runs *on*.
|
||||
/// The two are separate because several workspaces on one machine share one
|
||||
/// connection but must not share forwards.
|
||||
///
|
||||
/// `None` on a `TerminalView` means "not a remote-workspace pane" — a local pane
|
||||
/// or an SSH pane — and every path here falls back to the pane-addressed one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PaneWorkspace {
|
||||
/// Identity of the workspace on its machine.
|
||||
pub workspace: crate::core::session::WorkspaceId,
|
||||
/// How the machine is reached. Read for the WSL special case, which shares
|
||||
/// `localhost` with the Windows host and so needs no forward at all.
|
||||
pub target: crate::core::session::RemoteTarget,
|
||||
/// Names the connection for the daemon's lookup. **Secret-free**
|
||||
/// ([`NativeSshSpec::without_secrets`]) — the daemon only matches it against
|
||||
/// an already-authenticated connection, so no credential needs to ride here.
|
||||
///
|
||||
/// `None` for WSL, which has no SSH connection and needs none.
|
||||
pub spec: Option<Box<NativeSshSpec>>,
|
||||
}
|
||||
|
||||
impl PaneWorkspace {
|
||||
/// Whether this workspace shares `localhost` with the client, so a
|
||||
/// `localhost:PORT` link resolves without any forward (design §15's WSL
|
||||
/// exception).
|
||||
pub fn shares_localhost(&self) -> bool {
|
||||
matches!(self.target, crate::core::session::RemoteTarget::Wsl { .. })
|
||||
}
|
||||
|
||||
/// The route header a pane of this workspace opens its connection with.
|
||||
///
|
||||
/// **`channel: Pane`, not the default `Control`.** A remote `tty7-server`
|
||||
/// listens twice, and the two dialects are not interchangeable: a pane sent
|
||||
/// to the control socket gets an `InvalidData` on its first `Spawn`, which
|
||||
/// is how "the window opens but nothing runs in it" looked before this
|
||||
/// existed.
|
||||
///
|
||||
/// The spec travels secret-free, which is deliberate and is what
|
||||
/// [`PaneWorkspace::spec`] documents: the daemon matches it against the
|
||||
/// connection it already authenticated for this machine's control stream.
|
||||
/// If that connection is gone the daemon re-authenticates, and the router's
|
||||
/// setup relay is what carries the prompt back here.
|
||||
pub fn route_header(&self) -> anyhow::Result<crate::daemon::router::RouteHeader> {
|
||||
use crate::core::session::RemoteTarget;
|
||||
use crate::daemon::router::RouteHeader;
|
||||
let header = match (&self.target, &self.spec) {
|
||||
(RemoteTarget::Wsl { distro }, _) => RouteHeader::wsl(distro.clone()),
|
||||
// Like WSL, this target carries its own address and needs no spec.
|
||||
//
|
||||
// `--pane` is added *here* rather than by the router: `LocalStdio`
|
||||
// runs the argv verbatim (there is no shell command line for
|
||||
// `RouteChannel::bridge_command` to rewrite), so the caller is the
|
||||
// only one that can pick the dialect. Same choice the SSH path makes
|
||||
// one layer down, made explicit.
|
||||
(RemoteTarget::LocalStdio { program, args }, _) => {
|
||||
let mut argv: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
if !argv.contains(&"--pane") {
|
||||
argv.push("--pane");
|
||||
}
|
||||
RouteHeader::local_stdio(program.clone(), &argv)
|
||||
}
|
||||
(_, Some(spec)) => RouteHeader::ssh((**spec).clone()),
|
||||
(target, None) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"this workspace has no SSH connection details ({target:?}), so its panes \
|
||||
cannot be routed"
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(header.for_pane())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a pane's daemon connection lands.
|
||||
///
|
||||
/// A pane is the *only* thing in tty7 that can be on a different machine from
|
||||
/// the window showing it, and this is the whole of how it says so. The transport
|
||||
/// underneath is identical either way — the same local socket, the same
|
||||
/// `try_clone`, the same reader thread — because the local daemon forwards a
|
||||
/// routed connection byte for byte (design §6).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum PaneRoute {
|
||||
/// This machine's daemon. Every pane before remote workspaces existed, and
|
||||
/// still every pane of a local window: **not one byte on the wire changes**
|
||||
/// for these, because [`connect_routed`] writes nothing extra.
|
||||
#[default]
|
||||
Local,
|
||||
/// A remote workspace's machine. The connection opens with a route header
|
||||
/// and does not carry a `ClientMsg` until the daemon has acked it.
|
||||
Remote(Box<crate::daemon::router::RouteHeader>),
|
||||
/// A pane that belongs to a remote workspace whose machine cannot be
|
||||
/// addressed — no SSH details on file for it.
|
||||
///
|
||||
/// **Not `Local`.** Falling back to the local daemon would send this pane's
|
||||
/// `Kill { pane_id }` to a daemon where that id names somebody else's pane,
|
||||
/// so a route that cannot be built has to fail rather than land somewhere.
|
||||
/// Every connection through this variant returns the reason.
|
||||
Unroutable(String),
|
||||
}
|
||||
|
||||
impl PaneRoute {
|
||||
/// The route a pane of `workspace` takes; [`PaneRoute::Local`] when the pane
|
||||
/// belongs to no remote workspace.
|
||||
///
|
||||
/// Infallible on purpose: the callers that need a route most are the ones
|
||||
/// with nowhere to put an error (a close, a restore probe), and for those
|
||||
/// [`PaneRoute::Unroutable`] is the safe answer rather than the local
|
||||
/// daemon. The reason still surfaces — at connect time, from the one place
|
||||
/// that has somewhere to report it.
|
||||
pub fn for_workspace(workspace: Option<&PaneWorkspace>) -> PaneRoute {
|
||||
match workspace {
|
||||
None => PaneRoute::Local,
|
||||
Some(ws) => match ws.route_header() {
|
||||
Ok(header) => PaneRoute::Remote(Box::new(header)),
|
||||
Err(e) => PaneRoute::Unroutable(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The header this route prefixes its connection with, or `None` when it
|
||||
/// prefixes nothing.
|
||||
///
|
||||
/// The single place that decides whether a connection carries an extra
|
||||
/// frame, so "a local pane's wire bytes are unchanged" is one assertion
|
||||
/// rather than a reading of [`connect_routed`].
|
||||
pub fn header(&self) -> Option<&crate::daemon::router::RouteHeader> {
|
||||
match self {
|
||||
PaneRoute::Remote(header) => Some(header),
|
||||
PaneRoute::Local | PaneRoute::Unroutable(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this pane's failures are the *local* daemon's to answer for.
|
||||
///
|
||||
/// The distinction is not cosmetic. On a routed pane the local daemon is a
|
||||
/// byte forwarder: a connection that drops mid-`Spawn` says the far end
|
||||
/// failed, and the local daemon is fine. Recovery paths that restart it —
|
||||
/// which drains and kills every pane it hosts — would then let one
|
||||
/// unreachable remote destroy all of the user's local sessions.
|
||||
///
|
||||
/// `Unroutable` counts as not-local for the same reason: nothing was ever
|
||||
/// asked of the local daemon, so nothing about it is worth restarting.
|
||||
pub fn is_local(&self) -> bool {
|
||||
matches!(self, PaneRoute::Local)
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal whose PTY lives in the daemon. Mirrors `backend::Terminal`'s public
|
||||
/// surface so the view can treat the two interchangeably.
|
||||
pub struct RemoteTerminal {
|
||||
@@ -216,6 +370,18 @@ pub struct RemoteTerminal {
|
||||
/// panel's Outline. Positions are grid rows, so they can only be taken here
|
||||
/// on the client — the daemon has no grid.
|
||||
marks: crate::terminal::marks::Marks,
|
||||
/// Which machine this pane's connection landed on. Kept so the *other*
|
||||
/// operations a pane needs — `Kill`, a `List` at restore — go to the same
|
||||
/// daemon the pane lives in. A remote pane's id means nothing here, and
|
||||
/// sending `Kill { pane_id }` to the local daemon would name whichever local
|
||||
/// pane happened to be allocated the same number.
|
||||
route: PaneRoute,
|
||||
/// The event sink the reader thread publishes through, kept so a
|
||||
/// [`relink`](Self::relink) can start a *new* reader against the *same*
|
||||
/// channel. The view subscribes to `events` once, at construction, and
|
||||
/// never again — a relink that handed the daemon a fresh channel would
|
||||
/// leave the pane on screen and permanently deaf.
|
||||
proxy: EventProxy,
|
||||
reader_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -231,10 +397,29 @@ impl RemoteTerminal {
|
||||
cell_h: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
shell: Option<ShellSpec>,
|
||||
) -> anyhow::Result<(Self, u64)> {
|
||||
Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell)
|
||||
}
|
||||
|
||||
/// [`spawn`](Self::spawn) onto a particular machine.
|
||||
///
|
||||
/// The retry ladder below is about the **local** daemon — the one this
|
||||
/// process starts and owns — so it applies unchanged to a routed pane: a
|
||||
/// route header cannot be written to a socket nobody is listening on either.
|
||||
/// What it deliberately does *not* do is restart anything on the far side; a
|
||||
/// remote daemon that is missing or mismatched is `install`'s business, and
|
||||
/// it has already run by the time the ack arrives.
|
||||
pub fn spawn_on(
|
||||
route: &PaneRoute,
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
shell: Option<ShellSpec>,
|
||||
) -> anyhow::Result<(Self, u64)> {
|
||||
let retry_cwd = cwd.clone();
|
||||
let retry_shell = shell.clone();
|
||||
match Self::spawn_once(size, cell_w, cell_h, cwd, shell) {
|
||||
match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell) {
|
||||
Ok(term) => Ok(term),
|
||||
Err(first_err) if daemon_not_listening(&first_err) => {
|
||||
// Nothing is on the socket: the daemon died (crash, OOM, a stray
|
||||
@@ -246,7 +431,7 @@ impl RemoteTerminal {
|
||||
"daemon not running ({first_err}); starting one failed: {start_err}"
|
||||
));
|
||||
}
|
||||
Self::spawn_once(size, cell_w, cell_h, retry_cwd, retry_shell).map_err(
|
||||
Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell).map_err(
|
||||
|second_err| {
|
||||
anyhow::anyhow!(
|
||||
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
|
||||
@@ -254,7 +439,16 @@ impl RemoteTerminal {
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => {
|
||||
// **Local panes only.** On a routed pane the connection this reads
|
||||
// as "disconnected" belongs to the *remote* — the local daemon is
|
||||
// only forwarding bytes across it, and it is fine. Restarting it
|
||||
// would not fix anything on the far side, and `restart` drains and
|
||||
// kills every pane it hosts: one unreachable remote would take out
|
||||
// all of the user's local sessions. Report the far end's failure
|
||||
// instead.
|
||||
Err(first_err)
|
||||
if route.is_local() && daemon_disconnected_before_spawn_reply(&first_err) =>
|
||||
{
|
||||
// A live-but-old daemon can accept the connection, panic while
|
||||
// handling Spawn, and close before replying. Restart once so an
|
||||
// upgraded GUI cuts over cleanly instead of crashing on a stale
|
||||
@@ -264,7 +458,7 @@ impl RemoteTerminal {
|
||||
"daemon disconnected before Spawn reply ({first_err}); restart failed: {restart_err}"
|
||||
));
|
||||
}
|
||||
Self::spawn_once(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).map_err(|second_err| {
|
||||
anyhow::anyhow!(
|
||||
"daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}"
|
||||
)
|
||||
@@ -275,13 +469,14 @@ impl RemoteTerminal {
|
||||
}
|
||||
|
||||
fn spawn_once(
|
||||
route: &PaneRoute,
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
shell: Option<ShellSpec>,
|
||||
) -> anyhow::Result<(Self, u64)> {
|
||||
let mut stream = connect()?;
|
||||
let mut stream = connect_routed(route)?;
|
||||
let win = win_size(size, cell_w, cell_h);
|
||||
|
||||
// Ask the daemon to create the pane, then read its assigned id back. The
|
||||
@@ -305,7 +500,8 @@ impl RemoteTerminal {
|
||||
}
|
||||
};
|
||||
|
||||
let term = Self::from_stream(stream, size)?;
|
||||
let mut term = Self::from_stream(stream, size)?;
|
||||
term.route = route.clone();
|
||||
Ok((term, pane_id))
|
||||
}
|
||||
|
||||
@@ -314,14 +510,149 @@ impl RemoteTerminal {
|
||||
/// that the reader thread replays to rebuild the current screen + scrollback,
|
||||
/// followed by live `Output`.
|
||||
pub fn attach(size: TermSize, cell_w: u16, cell_h: u16, pane_id: u64) -> anyhow::Result<Self> {
|
||||
let mut stream = connect()?;
|
||||
Self::attach_on(&PaneRoute::Local, size, cell_w, cell_h, pane_id)
|
||||
}
|
||||
|
||||
/// [`attach`](Self::attach) on a particular machine. A remote workspace's
|
||||
/// pane ids are the *remote* daemon's, so a reattach has to take the same
|
||||
/// route the spawn did or it would find a stranger's pane — or, far more
|
||||
/// likely, none.
|
||||
pub fn attach_on(
|
||||
route: &PaneRoute,
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
pane_id: u64,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut stream = connect_routed(route)?;
|
||||
let win = win_size(size, cell_w, cell_h);
|
||||
|
||||
// Unlike Spawn there's no synchronous reply to wait for here: the Snapshot
|
||||
// arrives as the first framed message and is handled uniformly by the
|
||||
// reader thread (advance + Wakeup), so the screen rebuilds asynchronously.
|
||||
ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?;
|
||||
Self::from_stream(stream, size)
|
||||
let mut term = Self::from_stream(stream, size)?;
|
||||
term.route = route.clone();
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
// ── Design §10's pane half of a reconnect ────────────────────────────────
|
||||
//
|
||||
// For one pane: **reopen the channel, `Attach`, take the replay, resize to
|
||||
// this client's geometry.** It happens *in place* — the same `Term`, the
|
||||
// same event channel, the same shared signals the view already holds
|
||||
// handles to. Building a fresh `RemoteTerminal` and swapping it into the
|
||||
// view would look simpler and would silently break the pane: the view's
|
||||
// event pump subscribes to `events` once, at construction, and would go on
|
||||
// listening to the dead terminal's channel for ever.
|
||||
|
||||
/// The **blocking** half of a relink: reach the machine and re-`Attach`.
|
||||
///
|
||||
/// Split from [`adopt_relink`](Self::adopt_relink) because this is a
|
||||
/// network round trip — an SSH connect on a cold machine, possibly with a
|
||||
/// password sheet in the middle — and the terminal it is for is a gpui
|
||||
/// entity that can only be touched on the UI thread. So the wait happens on
|
||||
/// a background task and only the cheap swap runs where the view lives.
|
||||
pub fn open_relink(
|
||||
route: &PaneRoute,
|
||||
pane_id: u64,
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
) -> anyhow::Result<Stream> {
|
||||
let mut stream = connect_routed(route)?;
|
||||
ClientMsg::Attach {
|
||||
pane_id,
|
||||
size: win_size(size, cell_w, cell_h),
|
||||
}
|
||||
.encode(&mut stream)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// The **cheap** half: adopt an already-attached stream from
|
||||
/// [`open_relink`](Self::open_relink) as this pane's link.
|
||||
///
|
||||
/// # Why the grid is reset first
|
||||
///
|
||||
/// The daemon answers `Attach` by replaying its `ReplayRing` from the
|
||||
/// start. Advancing that onto a grid that still holds the pre-disconnect
|
||||
/// screen would append a second copy of everything. So the mirror is reset
|
||||
/// and the machine's own record becomes the whole truth — which is also the
|
||||
/// honest presentation of the replay boundary (design §10): the ring holds
|
||||
/// 8 MiB, a pane that outran it comes back with the daemon's current grid
|
||||
/// and **the middle is genuinely gone**. Nothing here interpolates it, and
|
||||
/// nothing upstream may imply it will fill in later.
|
||||
pub fn adopt_relink(
|
||||
&mut self,
|
||||
stream: Stream,
|
||||
route: &PaneRoute,
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
) -> anyhow::Result<()> {
|
||||
// Retire the old link first. No `Detach`: this path exists because the
|
||||
// socket is already gone, and on the one case where it is not (a
|
||||
// deliberate re-attach) the server treats a closed stream as a detach
|
||||
// anyway.
|
||||
if let Ok(writer) = self.writer.lock() {
|
||||
let _ = writer.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
if let Some(handle) = self.reader_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
// The retired reader has been joined, so everything it will ever emit is
|
||||
// already in the channel — including its `Exit`. Left there it would be
|
||||
// delivered *after* the swap and put "process exited" on a pane that is
|
||||
// demonstrably alive. Dropping the rest of that backlog is right for the
|
||||
// same reason the grid is reset below: it describes a screen the replay
|
||||
// is about to redraw from the machine's own record.
|
||||
while self.events.try_recv().is_ok() {}
|
||||
|
||||
let read_half = stream.try_clone()?;
|
||||
|
||||
// The dead link set these on its way out (`teardown`). A pane that is
|
||||
// being re-attached is by definition not finished, so they go back —
|
||||
// except `child_exited`, which records that the *shell* ended and is
|
||||
// still true no matter how many times the client reconnects.
|
||||
self.exited_flag.store(false, Ordering::SeqCst);
|
||||
self.exited = false;
|
||||
{
|
||||
use alacritty_terminal::vte::ansi::Handler as _;
|
||||
let mut term = self.term.lock();
|
||||
term.reset_state();
|
||||
}
|
||||
|
||||
let reader = Self::spawn_reader(
|
||||
self.term.clone(),
|
||||
self.proxy.clone(),
|
||||
read_half,
|
||||
ReaderSignals {
|
||||
cwd: self.cwd.clone(),
|
||||
shell: self.shell_state.clone(),
|
||||
remote: self.remote_context.clone(),
|
||||
agent: self.agent.clone(),
|
||||
agent_session: self.agent_session.clone(),
|
||||
exited: self.exited_flag.clone(),
|
||||
child_exited: self.child_exited.clone(),
|
||||
zle_reading: self.zle_reading.clone(),
|
||||
shell_vi_mode: self.shell_vi_mode.clone(),
|
||||
auth: self.auth_prompts.clone(),
|
||||
phase: self.ssh_phase.clone(),
|
||||
marks: self.marks.clone(),
|
||||
},
|
||||
);
|
||||
if let Ok(mut writer) = self.writer.lock() {
|
||||
*writer = stream;
|
||||
}
|
||||
self.reader_thread = Some(reader);
|
||||
self.route = route.clone();
|
||||
// Design §10's last step: "以新客户端的尺寸 Resize". `Attach` carries a
|
||||
// size but deliberately does not resize the PTY, so the geometry only
|
||||
// becomes real when this frame lands — and `synced_size = false` is what
|
||||
// lets it through when the size happens to equal the last one.
|
||||
self.synced_size = false;
|
||||
self.resize(size, cell_w, cell_h);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared tail of `spawn`/`attach`: build the local `Term`, split the socket
|
||||
@@ -363,7 +694,7 @@ impl RemoteTerminal {
|
||||
|
||||
let reader_thread = Self::spawn_reader(
|
||||
term.clone(),
|
||||
proxy,
|
||||
proxy.clone(),
|
||||
read_half,
|
||||
ReaderSignals {
|
||||
cwd: cwd.clone(),
|
||||
@@ -403,10 +734,34 @@ impl RemoteTerminal {
|
||||
agent,
|
||||
agent_session,
|
||||
marks,
|
||||
// Overwritten by the routed constructors; `from_stream` itself is
|
||||
// handed a stream whose destination it cannot see.
|
||||
route: PaneRoute::Local,
|
||||
proxy,
|
||||
reader_thread: Some(reader_thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Close this pane's link, leaving the pane running on its machine.
|
||||
///
|
||||
/// The same two frames `Drop` sends, without dropping: design §10's
|
||||
/// takeover needs the client to *stop being attached* while the view stays
|
||||
/// on screen in its read-only state.
|
||||
pub fn detach_link(&mut self) {
|
||||
if let Ok(mut writer) = self.writer.lock() {
|
||||
let _ = ClientMsg::Detach.encode(&mut *writer);
|
||||
let _ = writer.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
// The reader observes the close and runs its own teardown, so the pane
|
||||
// lands in exactly the state a dropped network link leaves it in — which
|
||||
// is the state design §10 wants after a takeover, reached by the code
|
||||
// path that is already exercised every time a connection fails.
|
||||
if let Some(handle) = self.reader_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
self.poll_exited();
|
||||
}
|
||||
|
||||
pub fn apply_user_config(&self, user_config: &crate::core::config::Config) {
|
||||
let mut term = self.term.lock();
|
||||
term.set_options(terminal_config_from_user(user_config));
|
||||
@@ -1035,15 +1390,38 @@ impl RemoteTerminal {
|
||||
/// error (no daemon, refused, malformed reply) so restore degrades to
|
||||
/// all-fresh.
|
||||
pub fn list_panes() -> Vec<crate::daemon::protocol::PaneInfo> {
|
||||
fn query() -> anyhow::Result<Vec<crate::daemon::protocol::PaneInfo>> {
|
||||
let mut stream = connect()?;
|
||||
ClientMsg::List.encode(&mut stream)?;
|
||||
match DaemonMsg::read(&mut stream)? {
|
||||
DaemonMsg::PaneList(list) => Ok(list),
|
||||
other => Err(anyhow::anyhow!("unexpected reply to List: {other:?}")),
|
||||
}
|
||||
Self::list_panes_on(&PaneRoute::Local)
|
||||
}
|
||||
|
||||
/// [`list_panes`](Self::list_panes) on a particular machine. A remote
|
||||
/// workspace restores from the *remote* daemon's registry; asking the local
|
||||
/// one would report every saved leaf as dead and respawn the lot, silently
|
||||
/// abandoning whatever was still running there — the precise failure remote
|
||||
/// workspaces exist to prevent.
|
||||
pub fn list_panes_on(route: &PaneRoute) -> Vec<crate::daemon::protocol::PaneInfo> {
|
||||
Self::try_list_panes_on(route).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`list_panes_on`](Self::list_panes_on) with the failure kept.
|
||||
///
|
||||
/// Swallowing the error into an empty list is right for *restore*, where
|
||||
/// "no answer" and "nothing alive" lead to the same action (spawn fresh).
|
||||
/// It is wrong for anything that **shows** liveness: on this machine an
|
||||
/// unreachable daemon really does mean no pane is running, but a routed
|
||||
/// `List` that failed says nothing about the remote's registry — the panes
|
||||
/// are very probably still there, we just could not ask. A picker that
|
||||
/// renders that as "stopped" tells the user their sessions are gone every
|
||||
/// time the link hiccups, so the two cases have to stay distinguishable
|
||||
/// this far up (see [`crate::terminal::pane_liveness`]).
|
||||
pub fn try_list_panes_on(
|
||||
route: &PaneRoute,
|
||||
) -> anyhow::Result<Vec<crate::daemon::protocol::PaneInfo>> {
|
||||
let mut stream = connect_routed(route)?;
|
||||
ClientMsg::List.encode(&mut stream)?;
|
||||
match DaemonMsg::read(&mut stream)? {
|
||||
DaemonMsg::PaneList(list) => Ok(list),
|
||||
other => Err(anyhow::anyhow!("unexpected reply to List: {other:?}")),
|
||||
}
|
||||
query().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Tell the daemon to terminate a pane's child and forget it, over a
|
||||
@@ -1052,7 +1430,16 @@ impl RemoteTerminal {
|
||||
/// and kept alive for restore). Best-effort: a missing daemon means there's
|
||||
/// nothing to kill anyway.
|
||||
pub fn kill_pane(pane_id: u64) {
|
||||
if let Ok(mut stream) = connect() {
|
||||
Self::kill_pane_on(&PaneRoute::Local, pane_id)
|
||||
}
|
||||
|
||||
/// [`kill_pane`](Self::kill_pane) on a particular machine.
|
||||
///
|
||||
/// Routing this one is not an optimisation. Pane ids are per-daemon, so
|
||||
/// `Kill { pane_id }` sent to the wrong daemon does not fail — it succeeds
|
||||
/// against a stranger.
|
||||
pub fn kill_pane_on(route: &PaneRoute, pane_id: u64) {
|
||||
if let Ok(mut stream) = connect_routed(route) {
|
||||
let _ = ClientMsg::Kill { pane_id }.encode(&mut stream);
|
||||
// Give the daemon a moment to read the frame before the connection
|
||||
// closes; a tiny blocking read of EOF is enough to order it.
|
||||
@@ -1411,6 +1798,58 @@ impl RemoteTerminal {
|
||||
query(pane_id).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Remote workspaces (design §15) ───────────────────────────────────────
|
||||
|
||||
/// Send one workspace-scoped request and return the daemon's reply.
|
||||
///
|
||||
/// The counterpart of the `pane_id`-addressed helpers above for a pane that
|
||||
/// lives on a *remote workspace*: there is no pane on the local daemon to
|
||||
/// name, so the request carries the workspace and a secret-free spec naming
|
||||
/// its machine, and the daemon resolves the connection the workspace already
|
||||
/// authenticated (`ssh::workspace::handle`).
|
||||
///
|
||||
/// `DaemonMsg::Error` is surfaced as an `Err` so callers can show it — a
|
||||
/// disconnected workspace has to be *reported*, not silently treated as an
|
||||
/// empty list.
|
||||
pub fn on_workspace(req: WorkspaceRequest) -> anyhow::Result<DaemonMsg> {
|
||||
let mut stream = connect()?;
|
||||
ClientMsg::OnWorkspace(Box::new(req)).encode(&mut stream)?;
|
||||
match DaemonMsg::read(&mut stream)? {
|
||||
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
|
||||
reply => Ok(reply),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`on_workspace`](Self::on_workspace) for the calls whose only sane failure
|
||||
/// mode is "show nothing": a list the panel is about to render.
|
||||
pub fn on_workspace_forwards(req: WorkspaceRequest) -> Vec<ManagedForward> {
|
||||
match Self::on_workspace(req) {
|
||||
Ok(DaemonMsg::ForwardList(list)) => list,
|
||||
Ok(other) => {
|
||||
log::warn!("unexpected reply to a workspace forward request: {other:?}");
|
||||
Vec::new()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("workspace forward request failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`WorkspaceRequest`] for `op` against `ws`, as seen from `view_pane`.
|
||||
pub fn workspace_request(
|
||||
ws: &PaneWorkspace,
|
||||
view_pane: u64,
|
||||
op: WorkspaceOp,
|
||||
) -> Option<WorkspaceRequest> {
|
||||
Some(WorkspaceRequest {
|
||||
workspace: ws.workspace,
|
||||
spec: ws.spec.clone()?,
|
||||
view_pane,
|
||||
op,
|
||||
})
|
||||
}
|
||||
|
||||
/// A pane's process tree and listening ports, for the details panel. One-shot
|
||||
/// over a short-lived control connection, like the forward queries — this is
|
||||
/// polled only while the panel is open, so it never rides the pane's hot
|
||||
@@ -1648,6 +2087,45 @@ fn connect() -> anyhow::Result<Stream> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a pane connection and, when the pane is a remote workspace's, hand it to
|
||||
/// the daemon's router before a single `ClientMsg` goes out.
|
||||
///
|
||||
/// **A local pane takes the identical path it always did.** `PaneRoute::Local`
|
||||
/// is `connect()` and nothing else — no extra frame, no extra round trip, no
|
||||
/// behaviour to regress. Every remote-specific step is inside the `Remote` arm.
|
||||
///
|
||||
/// The routed arm blocks for as long as the setup takes, including any question
|
||||
/// the daemon relays back (a password, install consent). Callers are already on
|
||||
/// a background thread for the plain `connect()`, and this is the same wait a
|
||||
/// pane on a cold SSH host has always had.
|
||||
fn connect_routed(route: &PaneRoute) -> anyhow::Result<Stream> {
|
||||
if let PaneRoute::Unroutable(reason) = route {
|
||||
return Err(anyhow::anyhow!("{reason}"));
|
||||
}
|
||||
let Some(header) = route.header() else {
|
||||
return connect();
|
||||
};
|
||||
|
||||
// WSL installs from the GUI process, never from the daemon: consent has to
|
||||
// be raised where it can be answered, and this machine *is* the machine
|
||||
// (see `install::wsl::ensure_wsl_server`'s own doc). The daemon's call a
|
||||
// moment later finds the binary in place and asks nobody.
|
||||
if let crate::daemon::router::RouteTarget::Wsl { distro } = &header.target {
|
||||
crate::daemon::install::wsl::ensure_wsl_server(distro)
|
||||
.map_err(|e| anyhow::anyhow!("prepare tty7-server in WSL `{distro}`: {e}"))?;
|
||||
}
|
||||
|
||||
let mut stream = connect()?;
|
||||
let ack = crate::daemon::router::negotiate(&mut stream, header)
|
||||
.map_err(|e| anyhow::anyhow!("route this pane to {}: {e}", header.describe()))?;
|
||||
log::debug!(
|
||||
"pane routed to {} over {}",
|
||||
header.describe(),
|
||||
ack.link.as_deref().unwrap_or("?")
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Config {
|
||||
Config {
|
||||
scrolling_history: user_config.scrollback_limit,
|
||||
@@ -1692,6 +2170,149 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Routing: a local pane must not change, a remote pane must not be local.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn ssh_workspace() -> PaneWorkspace {
|
||||
PaneWorkspace {
|
||||
workspace: crate::core::session::WorkspaceId::new(),
|
||||
target: crate::core::session::RemoteTarget::Direct {
|
||||
user: "me".into(),
|
||||
host: "build-box".into(),
|
||||
port: 22,
|
||||
},
|
||||
spec: Some(Box::new(
|
||||
serde_json::from_str(
|
||||
r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// **A local pane writes no extra byte.** The whole compatibility promise of
|
||||
/// this milestone in one assertion: `header()` is the only thing that puts a
|
||||
/// frame in front of a connection, and a pane with no workspace has none —
|
||||
/// so `connect_routed` is a bare `connect()` and the daemon's `handle_conn`
|
||||
/// sees the same opening `Spawn` it always did.
|
||||
#[test]
|
||||
fn a_local_pane_prefixes_nothing() {
|
||||
assert!(PaneRoute::Local.header().is_none());
|
||||
assert!(PaneRoute::for_workspace(None).header().is_none());
|
||||
assert!(matches!(PaneRoute::for_workspace(None), PaneRoute::Local));
|
||||
assert!(matches!(PaneRoute::default(), PaneRoute::Local));
|
||||
}
|
||||
|
||||
/// A remote workspace's pane routes to its machine, on the **pane** channel.
|
||||
///
|
||||
/// The channel is the load-bearing half: a header that defaulted to
|
||||
/// `Control` would reach the remote's control socket, where the first
|
||||
/// `Spawn` is an unknown frame.
|
||||
#[test]
|
||||
fn a_remote_pane_routes_to_its_machine_on_the_pane_channel() {
|
||||
let route = PaneRoute::for_workspace(Some(&ssh_workspace()));
|
||||
let header = route.header().expect("a remote pane is routed");
|
||||
assert_eq!(
|
||||
header.channel,
|
||||
crate::daemon::router::RouteChannel::Pane,
|
||||
"a pane must not be sent to the control socket"
|
||||
);
|
||||
assert_eq!(header.describe(), "ssh me@build-box:22");
|
||||
}
|
||||
|
||||
/// WSL routes by distro and carries no spec, because there is no connection
|
||||
/// to name (design §7.3).
|
||||
#[test]
|
||||
fn a_wsl_workspace_routes_by_distro() {
|
||||
let ws = PaneWorkspace {
|
||||
workspace: crate::core::session::WorkspaceId::new(),
|
||||
target: crate::core::session::RemoteTarget::Wsl {
|
||||
distro: "Ubuntu-22.04".into(),
|
||||
},
|
||||
spec: None,
|
||||
};
|
||||
let route = PaneRoute::for_workspace(Some(&ws));
|
||||
let header = route.header().expect("WSL is routed");
|
||||
assert_eq!(header.describe(), "wsl Ubuntu-22.04");
|
||||
assert_eq!(header.channel, crate::daemon::router::RouteChannel::Pane);
|
||||
}
|
||||
|
||||
/// A `--stdio` workspace on this computer routes to a child process and,
|
||||
/// crucially, asks it for the **pane** dialect.
|
||||
///
|
||||
/// `LocalStdio` runs its argv verbatim — there is no remote shell command
|
||||
/// line for the router's `bridge_command` to rewrite — so the `--pane` flag
|
||||
/// has to be added here. Without it the pane lands on the control socket
|
||||
/// and its first `Spawn` comes back `InvalidData`, which is exactly what
|
||||
/// "the window opens but nothing runs in it" looked like.
|
||||
#[test]
|
||||
fn a_local_stdio_workspace_routes_to_a_child_process_on_the_pane_dialect() {
|
||||
let ws = PaneWorkspace {
|
||||
workspace: crate::core::session::WorkspaceId::new(),
|
||||
target: crate::core::session::RemoteTarget::LocalStdio {
|
||||
program: "/tmp/tty7-server".into(),
|
||||
args: vec!["--stdio".into()],
|
||||
},
|
||||
spec: None,
|
||||
};
|
||||
let route = PaneRoute::for_workspace(Some(&ws));
|
||||
let header = route.header().expect("a local child is routable");
|
||||
assert_eq!(header.channel, crate::daemon::router::RouteChannel::Pane);
|
||||
match &header.target {
|
||||
crate::daemon::router::RouteTarget::LocalStdio { program, args } => {
|
||||
assert_eq!(program, "/tmp/tty7-server");
|
||||
assert_eq!(args, &vec!["--stdio".to_string(), "--pane".to_string()]);
|
||||
}
|
||||
other => panic!("wrong target: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// **A workspace that cannot be routed does not fall back to local.**
|
||||
///
|
||||
/// Pane ids are per-daemon, so a remote pane whose route is missing must not
|
||||
/// address the local daemon: `Kill { pane_id }` there would name a stranger's
|
||||
/// pane and succeed.
|
||||
#[test]
|
||||
fn an_unroutable_workspace_is_not_treated_as_local() {
|
||||
let ws = PaneWorkspace {
|
||||
workspace: crate::core::session::WorkspaceId::new(),
|
||||
target: crate::core::session::RemoteTarget::Alias {
|
||||
alias: "build-box".into(),
|
||||
},
|
||||
spec: None,
|
||||
};
|
||||
let route = PaneRoute::for_workspace(Some(&ws));
|
||||
assert!(matches!(route, PaneRoute::Unroutable(_)));
|
||||
assert!(route.header().is_none(), "nothing to route to");
|
||||
let err = connect_routed(&route).expect_err("must not reach the local daemon");
|
||||
assert!(err.to_string().contains("cannot be routed"), "{err}");
|
||||
}
|
||||
|
||||
/// **Only a local pane may make the local daemon restart.**
|
||||
///
|
||||
/// `spawn`'s recovery path reads "the connection dropped before the `Spawn`
|
||||
/// reply" as a stale local daemon and restarts it — which drains and kills
|
||||
/// every pane it hosts. On a routed pane that same symptom means the *far
|
||||
/// end* failed while the local daemon was faithfully forwarding bytes, so
|
||||
/// acting on it would let one unreachable remote destroy every local
|
||||
/// session the user had open. Observed for real: a remote whose
|
||||
/// `tty7-server` could not be exec'd took the local daemon down with it.
|
||||
#[test]
|
||||
fn only_a_local_pane_may_restart_the_local_daemon() {
|
||||
assert!(PaneRoute::Local.is_local());
|
||||
assert!(PaneRoute::for_workspace(None).is_local());
|
||||
|
||||
assert!(
|
||||
!PaneRoute::for_workspace(Some(&ssh_workspace())).is_local(),
|
||||
"a routed pane's disconnect is the remote's failure, not the local daemon's"
|
||||
);
|
||||
assert!(
|
||||
!PaneRoute::Unroutable("no ssh details".into()).is_local(),
|
||||
"nothing was ever asked of the local daemon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_keyboard_negotiation_reports_the_requested_mode() {
|
||||
let config = terminal_config_from_user(&crate::core::config::Config::default());
|
||||
|
||||
+1250
-124
File diff suppressed because it is too large
Load Diff
+664
-186
File diff suppressed because it is too large
Load Diff
+656
-144
@@ -27,7 +27,7 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
@@ -41,6 +41,7 @@ use gpui_component::{
|
||||
};
|
||||
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::host_ops::{HostOps, MTime, WatchSub};
|
||||
|
||||
/// Refuse to open files larger than this: the component's code editor is rated
|
||||
/// to ~50K lines, and a multi-megabyte blob is almost never what a terminal
|
||||
@@ -60,7 +61,35 @@ pub(crate) struct OpenFile {
|
||||
pub(crate) dirty: bool,
|
||||
/// mtime of the content we last loaded from / saved to disk; used to drop
|
||||
/// watcher echoes of our own saves.
|
||||
disk_mtime: Option<SystemTime>,
|
||||
///
|
||||
/// [`MTime`], not `SystemTime`, and nanosecond-precise on purpose: the echo
|
||||
/// test is mtime equality, so a coarser clock would swallow a genuine
|
||||
/// external edit that landed in the same tick as our own write.
|
||||
disk_mtime: Option<MTime>,
|
||||
/// Bumped on every buffer change, so a save that lands can tell whether the
|
||||
/// text it wrote is still the text in the buffer.
|
||||
edit_seq: u64,
|
||||
/// The `edit_seq` the in-flight write's snapshot was taken at, or `None`
|
||||
/// when nothing is being written. Also the single-flight latch: a second
|
||||
/// ⌘S while this is set queues rather than races.
|
||||
saving: Option<u64>,
|
||||
/// A save was asked for while one was in flight. Re-issued when that one
|
||||
/// lands, so the last content the user asked to save is the content on disk.
|
||||
save_pending: bool,
|
||||
/// The user answered "Save" to a close prompt, so this buffer closes once
|
||||
/// its write lands.
|
||||
///
|
||||
/// Lives on the buffer rather than being threaded through the save call
|
||||
/// because a save can be *queued* behind one already in flight: passing it
|
||||
/// as an argument meant the queued request's intent was dropped and the
|
||||
/// in-flight one's was replayed, so a close silently did nothing.
|
||||
save_then_close: bool,
|
||||
/// Bumped every time a reload is issued; a landing that is no longer the
|
||||
/// newest discards itself. Two watcher batches can put two reads in flight,
|
||||
/// and without this the older one can land last and install stale text
|
||||
/// *marked clean* — a buffer that no longer matches disk and never
|
||||
/// re-checks.
|
||||
reload_seq: u64,
|
||||
/// Disk changed under unsaved edits: show the reload/keep banner instead
|
||||
/// of silently clobbering either side.
|
||||
pub(crate) conflict: bool,
|
||||
@@ -129,29 +158,55 @@ impl TabCode {
|
||||
/// App-global editor infrastructure shared by every tab's panel.
|
||||
pub(crate) struct EditorPanelState {
|
||||
/// Watches the parent directories of open files (across all tabs) for
|
||||
/// external changes. Rebuilt whenever any open set changes; `None` while
|
||||
/// nothing is open anywhere.
|
||||
watcher: Option<notify::RecommendedWatcher>,
|
||||
/// Feeds changed paths from the watcher thread into the UI-side reload
|
||||
/// loop spawned in [`EditorPanelState::new`].
|
||||
events_tx: smol::channel::Sender<PathBuf>,
|
||||
/// external changes.
|
||||
///
|
||||
/// One long-lived subscription whose set moves with the open files, rather
|
||||
/// than a watcher rebuilt per open — remotely, a rebuild is a round trip
|
||||
/// and a server-side watcher recreated every time a file is opened or
|
||||
/// closed. `Arc` because `set_dirs` is itself a host call.
|
||||
watch: Option<Arc<WatchSub>>,
|
||||
/// A subscription is being opened; keeps a burst of opens from asking for
|
||||
/// one each.
|
||||
watch_opening: bool,
|
||||
/// A `set_dirs` is in flight, and whether the set moved again while it was.
|
||||
///
|
||||
/// `set_dirs` replaces the watched set wholesale, so two of them in flight
|
||||
/// resolve by arrival order, not issue order — and the loser strands the
|
||||
/// watcher on a stale set *permanently*, because the caller only re-issues
|
||||
/// when the desired set changes. Single-flight instead: one out at a time,
|
||||
/// re-issued from the current set when it lands.
|
||||
watch_busy: bool,
|
||||
watch_dirty: bool,
|
||||
/// The directories the watch spans — every open file's parent.
|
||||
watched_dirs: HashSet<PathBuf>,
|
||||
/// The open files themselves. The watch is per-directory, so this is what
|
||||
/// separates "a file we care about changed" from "something else in that
|
||||
/// directory did".
|
||||
watched_files: HashSet<PathBuf>,
|
||||
/// Feeds changed paths from the watch into the UI-side reload loop spawned
|
||||
/// in [`EditorPanelState::new`].
|
||||
events_tx: smol::channel::Sender<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl EditorPanelState {
|
||||
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
|
||||
// The reload loop lives for the app: it debounces watcher pings and
|
||||
// routes them to `handle_external_change` on the UI thread.
|
||||
let (tx, rx) = smol::channel::unbounded::<PathBuf>();
|
||||
let (tx, rx) = smol::channel::unbounded::<Vec<PathBuf>>();
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
while let Ok(first) = rx.recv().await {
|
||||
cx.background_executor().timer(RELOAD_DEBOUNCE).await;
|
||||
let mut changed: HashSet<PathBuf> = HashSet::from([first]);
|
||||
let mut changed: HashSet<PathBuf> = first.into_iter().collect();
|
||||
while let Ok(more) = rx.try_recv() {
|
||||
changed.insert(more);
|
||||
changed.extend(more);
|
||||
}
|
||||
let ok = app.update_in(cx, |app, window, cx| {
|
||||
for path in changed {
|
||||
app.editor_handle_external_change(&path, window, cx);
|
||||
// The watch is on directories, so most of what arrives
|
||||
// is about files nobody has open.
|
||||
if app.editor.watched_files.contains(&path) {
|
||||
app.editor_handle_external_change(&path, window, cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
if ok.is_err() {
|
||||
@@ -161,7 +216,12 @@ impl EditorPanelState {
|
||||
})
|
||||
.detach();
|
||||
Self {
|
||||
watcher: None,
|
||||
watch: None,
|
||||
watch_opening: false,
|
||||
watch_busy: false,
|
||||
watch_dirty: false,
|
||||
watched_dirs: HashSet::new(),
|
||||
watched_files: HashSet::new(),
|
||||
events_tx: tx,
|
||||
}
|
||||
}
|
||||
@@ -237,6 +297,78 @@ fn looks_binary(bytes: &[u8]) -> bool {
|
||||
bytes.iter().take(8192).any(|b| *b == 0)
|
||||
}
|
||||
|
||||
/// What a watcher event means for one buffer holding the changed file.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ExternalChange {
|
||||
/// Not a change we should act on — our own write, or one we cannot yet
|
||||
/// distinguish from our own write.
|
||||
Ignore,
|
||||
/// Disk moved under unsaved edits: raise the banner and let the user pick.
|
||||
Conflict,
|
||||
/// Clean buffer, changed file: take the new content silently.
|
||||
Reload,
|
||||
}
|
||||
|
||||
/// Decide what a changed file means for one buffer.
|
||||
///
|
||||
/// Pulled out of the event handler because it is the whole of the
|
||||
/// external-change contract and the only part of it worth testing directly:
|
||||
/// everything around it is GPUI plumbing.
|
||||
///
|
||||
/// `saving` is the subtle one. While our own write is in flight `disk_mtime`
|
||||
/// still names the *previous* content, so the echo test below would call our
|
||||
/// own save an external change and — on a clean buffer — reload the file out
|
||||
/// from under the write. The write's landing sets the new mtime; anything
|
||||
/// genuinely external gets reported again after it.
|
||||
fn classify_external_change(
|
||||
saving: bool,
|
||||
dirty: bool,
|
||||
disk_mtime: Option<MTime>,
|
||||
observed: Option<MTime>,
|
||||
) -> ExternalChange {
|
||||
if saving {
|
||||
return ExternalChange::Ignore;
|
||||
}
|
||||
// Our own save's echo: the mtime matches what we last wrote or loaded.
|
||||
// `Some` on both sides deliberately — a filesystem with no mtime cannot
|
||||
// prove an echo, and guessing "echo" there would drop real changes.
|
||||
if observed.is_some() && observed == disk_mtime {
|
||||
return ExternalChange::Ignore;
|
||||
}
|
||||
if dirty {
|
||||
ExternalChange::Conflict
|
||||
} else {
|
||||
ExternalChange::Reload
|
||||
}
|
||||
}
|
||||
|
||||
/// What a landed write does to the buffer it wrote.
|
||||
///
|
||||
/// Separated for the same reason: this is the three-way answer that the ⌘S
|
||||
/// exemption in contract §1 turns on, and it is pure.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct SaveLanding {
|
||||
/// The buffer still holds what reached disk, so it may be marked clean.
|
||||
clean: bool,
|
||||
/// Another save was asked for while this one flew; re-issue it.
|
||||
requeue: bool,
|
||||
}
|
||||
|
||||
/// Settle an in-flight write.
|
||||
///
|
||||
/// `wrote_seq` is the buffer's edit counter when the snapshot was taken and
|
||||
/// `current_seq` is where it is now: unequal means the user kept typing, so the
|
||||
/// bytes on disk are already stale and the buffer stays dirty.
|
||||
///
|
||||
/// A failed write never requeues — a path that cannot be written would
|
||||
/// otherwise re-issue forever, one notification per round.
|
||||
fn settle_save(ok: bool, wrote_seq: u64, current_seq: u64, pending: bool) -> SaveLanding {
|
||||
SaveLanding {
|
||||
clean: ok && wrote_seq == current_seq,
|
||||
requeue: ok && pending,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tty7App: open / save / close / external reload.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,44 +401,102 @@ impl Tty7App {
|
||||
/// Rebuild the external-change watcher over every tab's open files.
|
||||
/// Watches each file's *parent directory* (non-recursively): editors that
|
||||
/// save via rename replace the inode, which a direct file watch loses.
|
||||
fn editor_rebuild_watcher(&mut self) {
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
self.editor.watcher = None;
|
||||
let watched: HashSet<PathBuf> = self
|
||||
fn editor_rebuild_watcher(&mut self, cx: &mut Context<Self>) {
|
||||
let files: HashSet<PathBuf> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.filter_map(|t| t.code.as_deref())
|
||||
.flat_map(|c| c.files.iter().map(|f| f.path.clone()))
|
||||
.collect();
|
||||
if watched.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dirs: HashSet<PathBuf> = watched
|
||||
let dirs: HashSet<PathBuf> = files
|
||||
.iter()
|
||||
.filter_map(|p| p.parent().map(Path::to_path_buf))
|
||||
.collect();
|
||||
let tx = self.editor.events_tx.clone();
|
||||
let handler = move |res: notify::Result<notify::Event>| {
|
||||
let Ok(event) = res else { return };
|
||||
for p in &event.paths {
|
||||
if watched.contains(p) {
|
||||
let _ = tx.try_send(p.clone());
|
||||
}
|
||||
}
|
||||
self.editor.watched_files = files;
|
||||
if dirs == self.editor.watched_dirs {
|
||||
return;
|
||||
}
|
||||
self.editor.watched_dirs = dirs;
|
||||
self.editor_watch_apply(cx);
|
||||
}
|
||||
|
||||
/// Push `editor.watched_dirs` at the subscription, opening one first if
|
||||
/// there isn't one yet.
|
||||
///
|
||||
/// Split from [`editor_rebuild_watcher`](Self::editor_rebuild_watcher)
|
||||
/// because that one returns early when the set hasn't moved — which is
|
||||
/// right for a caller reacting to an open or a close, and wrong for the
|
||||
/// landing below, whose whole job is to apply a set that moved while there
|
||||
/// was nothing to apply it to.
|
||||
fn editor_watch_apply(&mut self, cx: &mut Context<Self>) {
|
||||
let want: Vec<PathBuf> = self.editor.watched_dirs.iter().cloned().collect();
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
return;
|
||||
};
|
||||
let mut watcher = match notify::recommended_watcher(handler) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("editor: external-change watcher unavailable: {e}");
|
||||
|
||||
if let Some(sub) = self.editor.watch.clone() {
|
||||
if self.editor.watch_busy {
|
||||
self.editor.watch_dirty = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
for dir in dirs {
|
||||
if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
|
||||
log::warn!("editor: failed to watch {}: {e}", dir.display());
|
||||
}
|
||||
self.editor.watch_busy = true;
|
||||
HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
move |_| sub.set_dirs(&want),
|
||||
|app: &mut Self, result: std::io::Result<()>, cx| {
|
||||
app.editor.watch_busy = false;
|
||||
if let Err(e) = result {
|
||||
log::warn!("editor: could not update the watched set: {e}");
|
||||
}
|
||||
if std::mem::take(&mut app.editor.watch_dirty) {
|
||||
app.editor_watch_apply(cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.editor.watcher = Some(watcher);
|
||||
if self.editor.watch_opening {
|
||||
// The landing re-reads `watched_dirs`, so a set that moved while
|
||||
// the subscription was opening is applied when it arrives.
|
||||
return;
|
||||
}
|
||||
self.editor.watch_opening = true;
|
||||
let opened_with = self.editor.watched_dirs.clone();
|
||||
HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
{
|
||||
let want = want.clone();
|
||||
move |h| h.watch(&want).map(Arc::new)
|
||||
},
|
||||
move |app, result: std::io::Result<Arc<WatchSub>>, cx| {
|
||||
app.editor.watch_opening = false;
|
||||
let sub = match result {
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
log::warn!("editor: external-change watcher unavailable: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let events = sub.events().clone();
|
||||
app.editor.watch = Some(sub);
|
||||
cx.spawn(async move |app, cx| {
|
||||
while let Ok(batch) = events.recv().await {
|
||||
let ok = app.update(cx, |app, _cx| {
|
||||
let _ = app.editor.events_tx.try_send(batch);
|
||||
});
|
||||
if ok.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
if app.editor.watched_dirs != opened_with {
|
||||
app.editor_watch_apply(cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Open `path` in the active tab's editor (activating an existing file tab
|
||||
@@ -325,61 +515,104 @@ impl Tty7App {
|
||||
// file tree lives in the right panel and stays clickable even while the
|
||||
// diff overlay covers the column.
|
||||
self.raise_code_overlay();
|
||||
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
if let Some(code) = self.tab_code_mut()
|
||||
&& let Some(ix) = code.files.iter().position(|f| f.path == path)
|
||||
{
|
||||
code.visible = true;
|
||||
// Activating always surfaces to the front of the strip: the strip
|
||||
// is MRU-ordered and only its head fits on screen (see
|
||||
// `render_editor_tabs`), so the active file must live there.
|
||||
let f = code.files.remove(ix);
|
||||
code.files.insert(0, f);
|
||||
code.active = 0;
|
||||
self.focus_editor(window, cx);
|
||||
cx.notify();
|
||||
// The already-open check runs twice: once here against the path as
|
||||
// given, so the overwhelmingly common case (a click on a tree row,
|
||||
// whose path is already canonical) costs nothing, and once more when
|
||||
// the canonical path comes back, which is the one that is actually
|
||||
// authoritative.
|
||||
if self.editor_activate_open(path, window, cx) {
|
||||
return;
|
||||
}
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(meta) if meta.len() > MAX_FILE_BYTES => {
|
||||
window.push_notification(
|
||||
format!(
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
return;
|
||||
};
|
||||
let p = path.to_path_buf();
|
||||
HostOps::run_in(
|
||||
host,
|
||||
window,
|
||||
cx,
|
||||
// The failure arm carries the finished message rather than an
|
||||
// error value: every one of these is phrased around the path, and
|
||||
// the path is only settled once `canonicalize` has run out here.
|
||||
move |h| -> Result<(PathBuf, String, Option<MTime>), String> {
|
||||
// Canonicalize first — it decides identity, and two paths to
|
||||
// one file must not become two buffers. A failure keeps the
|
||||
// path as given, which is the habit this call site has always
|
||||
// had.
|
||||
let path = h.canonicalize(&p).unwrap_or(p);
|
||||
let meta = match h.stat(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return Err(format!("Can't open {}: {e}", path.display())),
|
||||
};
|
||||
if meta.len > MAX_FILE_BYTES {
|
||||
return Err(format!(
|
||||
"\"{}\" is too large for the editor ({} MB)",
|
||||
path.display(),
|
||||
meta.len() / (1024 * 1024)
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(format!("Can't open {}: {e}", path.display()), cx);
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
window.push_notification(format!("Can't read {}: {e}", path.display()), cx);
|
||||
return;
|
||||
}
|
||||
meta.len / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
let bytes = match h.read_file(&path, MAX_FILE_BYTES) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Can't read {}: {e}", path.display())),
|
||||
};
|
||||
if looks_binary(&bytes) {
|
||||
return Err(format!("\"{}\" looks like a binary file", path.display()));
|
||||
}
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|_| format!("\"{}\" is not valid UTF-8", path.display()))?;
|
||||
Ok((path, text, meta.mtime))
|
||||
},
|
||||
move |app, opened, window, cx| match opened {
|
||||
Ok((path, text, mtime)) => app.editor_install_file(path, text, mtime, window, cx),
|
||||
Err(message) => window.push_notification(message, cx),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Bring an already-open `path` to the front, reporting whether it was
|
||||
/// open at all.
|
||||
fn editor_activate_open(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(code) = self.tab_code_mut() else {
|
||||
return false;
|
||||
};
|
||||
if looks_binary(&bytes) {
|
||||
window.push_notification(
|
||||
format!("\"{}\" looks like a binary file", path.display()),
|
||||
cx,
|
||||
);
|
||||
let Some(ix) = code.files.iter().position(|f| f.path == *path) else {
|
||||
return false;
|
||||
};
|
||||
code.visible = true;
|
||||
// Activating always surfaces to the front of the strip: the strip
|
||||
// is MRU-ordered and only its head fits on screen (see
|
||||
// `render_editor_tabs`), so the active file must live there.
|
||||
let f = code.files.remove(ix);
|
||||
code.files.insert(0, f);
|
||||
code.active = 0;
|
||||
self.focus_editor(window, cx);
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Put a file that finished loading into the active tab.
|
||||
fn editor_install_file(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
text: String,
|
||||
mtime: Option<MTime>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// The canonical path is the authoritative identity, and the load took
|
||||
// long enough that the file may have been opened by another route in
|
||||
// the meantime.
|
||||
if self.editor_activate_open(&path, window, cx) {
|
||||
return;
|
||||
}
|
||||
if self.tabs.get(self.active).is_none() {
|
||||
return;
|
||||
}
|
||||
let text = match String::from_utf8(bytes) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
window.push_notification(format!("\"{}\" is not valid UTF-8", path.display()), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
let language = language_for_path(&path);
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -412,9 +645,11 @@ impl Tty7App {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !f.dirty {
|
||||
f.dirty = true;
|
||||
}
|
||||
f.dirty = true;
|
||||
// Every edit moves the buffer away from whatever an
|
||||
// in-flight save is writing, which is how that save knows
|
||||
// not to declare the buffer clean when it lands.
|
||||
f.edit_seq = f.edit_seq.wrapping_add(1);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -433,6 +668,11 @@ impl Tty7App {
|
||||
input,
|
||||
dirty: false,
|
||||
disk_mtime: mtime,
|
||||
edit_seq: 0,
|
||||
saving: None,
|
||||
save_pending: false,
|
||||
save_then_close: false,
|
||||
reload_seq: 0,
|
||||
conflict: false,
|
||||
preview: false,
|
||||
wrap: false,
|
||||
@@ -442,7 +682,7 @@ impl Tty7App {
|
||||
);
|
||||
code.active = 0;
|
||||
code.visible = true;
|
||||
self.editor_rebuild_watcher();
|
||||
self.editor_rebuild_watcher(cx);
|
||||
self.focus_editor(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -520,25 +760,136 @@ impl Tty7App {
|
||||
|
||||
/// `EditorSave` (⌘S): write the active buffer back to its path.
|
||||
pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(code) = self.tab_code_mut() else {
|
||||
let Some(id) = self
|
||||
.tab_code()
|
||||
.and_then(|c| c.active_file())
|
||||
.map(|f| f.input.entity_id())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let active = code.active;
|
||||
let Some(f) = code.files.get_mut(active) else {
|
||||
self.editor_save_file(id, false, window, cx);
|
||||
}
|
||||
|
||||
/// Write one buffer back to its path, optionally closing it once the write
|
||||
/// lands.
|
||||
///
|
||||
/// The write is asynchronous (contract §1 exempts this): ⌘S no longer
|
||||
/// 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:
|
||||
///
|
||||
/// | Case | Handling |
|
||||
/// |---|---|
|
||||
/// | The user keeps typing while the write is in flight | The snapshot's `edit_seq` is compared on landing; a buffer that moved stays dirty, because it no longer matches what reached disk |
|
||||
/// | Two ⌘S in a row | Single-flight. The second sets `save_pending` and is re-issued when the first lands, so the newest content wins and two writes never race for the same file |
|
||||
/// | The write fails | The buffer stays dirty, `save_pending` is dropped so a failing path can't notify in a loop, and the error is shown |
|
||||
///
|
||||
/// The buffer is named by the `EntityId` of its input, which is the only
|
||||
/// identity that survives the wait. A tab index does not: closing or
|
||||
/// reordering a *terminal* tab shifts `self.tabs` under an in-flight write,
|
||||
/// and the landing would then either miss the buffer — stranding `saving`
|
||||
/// set, which silently disables every later save *and* every external-change
|
||||
/// check for that file — or find a different buffer of the same path in
|
||||
/// another tab and settle that one instead.
|
||||
fn editor_save_file(
|
||||
&mut self,
|
||||
id: gpui::EntityId,
|
||||
then_close: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Resolved before the buffer is borrowed: the write cannot go anywhere
|
||||
// without a machine to write to, and taking it after would hold a
|
||||
// mutable borrow of `self` across an immutable read of it.
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
return;
|
||||
};
|
||||
let text = f.input.read(cx).text().to_string();
|
||||
match std::fs::write(&f.path, &text) {
|
||||
Ok(()) => {
|
||||
f.dirty = false;
|
||||
f.conflict = false;
|
||||
f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok();
|
||||
cx.notify();
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(format!("Save failed: {e}"), cx);
|
||||
}
|
||||
let Some(f) = self.editor_file_mut(id) else {
|
||||
return;
|
||||
};
|
||||
// Sticky, and OR-accumulated: a close asked for while a plain ⌘S is in
|
||||
// flight must still close when that write lands.
|
||||
f.save_then_close |= then_close;
|
||||
if f.saving.is_some() {
|
||||
// A write is already out for this buffer. Queue rather than race:
|
||||
// two writes of the same file can land on disk in either order, and
|
||||
// the loser would leave stale content behind.
|
||||
f.save_pending = true;
|
||||
return;
|
||||
}
|
||||
let seq = f.edit_seq;
|
||||
f.saving = Some(seq);
|
||||
let text = f.input.read(cx).text().to_string();
|
||||
let target = f.path.clone();
|
||||
HostOps::run_in(
|
||||
host,
|
||||
window,
|
||||
cx,
|
||||
// One call, one round trip: the write answers with its own
|
||||
// post-write metadata, so no external edit can land between the
|
||||
// write and a follow-up `stat` and be mistaken for ours.
|
||||
move |h| h.write_file(&target, text.as_bytes()).map(|m| m.mtime),
|
||||
move |app, result: std::io::Result<Option<MTime>>, window, cx| {
|
||||
let Some(f) = app.editor_file_mut(id) else {
|
||||
return;
|
||||
};
|
||||
f.saving = None;
|
||||
let landing = settle_save(
|
||||
result.is_ok(),
|
||||
seq,
|
||||
f.edit_seq,
|
||||
std::mem::take(&mut f.save_pending),
|
||||
);
|
||||
match result {
|
||||
Ok(mtime) => {
|
||||
// The mtime of the bytes we just wrote, so the watcher
|
||||
// echo of our own save is recognised and ignored.
|
||||
f.disk_mtime = mtime;
|
||||
}
|
||||
Err(e) => HostOps::notify_err(window, cx, "Save failed", &e),
|
||||
}
|
||||
if landing.clean {
|
||||
f.dirty = false;
|
||||
f.conflict = false;
|
||||
}
|
||||
if landing.requeue {
|
||||
// `save_then_close` stays on the buffer, so the queued
|
||||
// round inherits it rather than the first caller's copy.
|
||||
app.editor_save_file(id, false, window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let close = app
|
||||
.editor_file_mut(id)
|
||||
.is_some_and(|f| std::mem::take(&mut f.save_then_close) && !f.dirty);
|
||||
if close && let Some((tab_ix, ix)) = app.editor_file_position(id) {
|
||||
app.editor_remove_file_in(tab_ix, ix, cx);
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// One open buffer, by the identity of its input entity.
|
||||
///
|
||||
/// Scans every tab: a file may be open in more than one, and the entity id
|
||||
/// is what tells those buffers apart.
|
||||
fn editor_file_mut(&mut self, id: gpui::EntityId) -> Option<&mut OpenFile> {
|
||||
self.tabs
|
||||
.iter_mut()
|
||||
.filter_map(|t| t.code.as_deref_mut())
|
||||
.flat_map(|c| c.files.iter_mut())
|
||||
.find(|f| f.input.entity_id() == id)
|
||||
}
|
||||
|
||||
/// Where a buffer sits right now, as `(tab index, file index)`. Both move,
|
||||
/// so this is only ever valid for the duration of one UI-thread turn.
|
||||
fn editor_file_position(&self, id: gpui::EntityId) -> Option<(usize, usize)> {
|
||||
self.tabs.iter().enumerate().find_map(|(tab_ix, t)| {
|
||||
let code = t.code.as_deref()?;
|
||||
let ix = code.files.iter().position(|f| f.input.entity_id() == id)?;
|
||||
Some((tab_ix, ix))
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the file tab at `ix`. Dirty buffers get a native three-way prompt
|
||||
@@ -564,28 +915,21 @@ impl Tty7App {
|
||||
&["Save", "Discard", "Cancel"],
|
||||
cx,
|
||||
);
|
||||
// The prompt is awaited, so the buffer is named by its input entity
|
||||
// rather than by an index that closing another tab would shift.
|
||||
let id = f.input.entity_id();
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
let Ok(choice) = answer.await else { return };
|
||||
let _ = app.update_in(cx, |app, window, cx| match choice {
|
||||
0 => {
|
||||
// Save, then close. Save failure keeps the tab open.
|
||||
let prev_active = app.tab_code().map(|c| c.active);
|
||||
if let Some(code) = app.tab_code_mut() {
|
||||
code.active = ix;
|
||||
}
|
||||
app.editor_save_active(window, cx);
|
||||
if let (Some(code), Some(prev)) = (app.tab_code_mut(), prev_active) {
|
||||
code.active = prev;
|
||||
}
|
||||
if app
|
||||
.tab_code()
|
||||
.and_then(|c| c.files.get(ix))
|
||||
.is_some_and(|f| !f.dirty)
|
||||
{
|
||||
app.editor_remove_file(ix, cx);
|
||||
// Save, then close — the close rides on the write landing (see
|
||||
// `editor_save_file`), so a failed save keeps the tab open
|
||||
// without the caller having to re-check anything.
|
||||
0 => app.editor_save_file(id, true, window, cx),
|
||||
1 => {
|
||||
if let Some((tab_ix, ix)) = app.editor_file_position(id) {
|
||||
app.editor_remove_file_in(tab_ix, ix, cx);
|
||||
}
|
||||
}
|
||||
1 => app.editor_remove_file(ix, cx),
|
||||
_ => {}
|
||||
});
|
||||
})
|
||||
@@ -616,7 +960,18 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
fn editor_remove_file(&mut self, ix: usize, cx: &mut Context<Self>) {
|
||||
let Some(code) = self.tab_code_mut() else {
|
||||
self.editor_remove_file_in(self.active, ix, cx);
|
||||
}
|
||||
|
||||
/// [`editor_remove_file`](Self::editor_remove_file) for a named tab — the
|
||||
/// save-then-close path lands after an await, by which time the active tab
|
||||
/// may not be the one the file is in.
|
||||
fn editor_remove_file_in(&mut self, tab_ix: usize, ix: usize, cx: &mut Context<Self>) {
|
||||
let Some(code) = self
|
||||
.tabs
|
||||
.get_mut(tab_ix)
|
||||
.and_then(|t| t.code.as_deref_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if ix >= code.files.len() {
|
||||
@@ -626,7 +981,7 @@ impl Tty7App {
|
||||
if code.active >= ix && code.active > 0 {
|
||||
code.active -= 1;
|
||||
}
|
||||
self.editor_rebuild_watcher();
|
||||
self.editor_rebuild_watcher(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -639,7 +994,31 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
return;
|
||||
};
|
||||
let p = path.to_path_buf();
|
||||
let landed = p.clone();
|
||||
HostOps::run_in(
|
||||
host,
|
||||
window,
|
||||
cx,
|
||||
move |h| h.stat(&p).ok().and_then(|m| m.mtime),
|
||||
move |app, mtime, window, cx| {
|
||||
app.editor_apply_external_change(&landed, mtime, window, cx)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Decide what a changed file means for each buffer holding it, once the
|
||||
/// host has answered with its mtime.
|
||||
fn editor_apply_external_change(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
mtime: Option<MTime>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut reload: Vec<(usize, usize)> = Vec::new();
|
||||
let mut changed = false;
|
||||
for (tab_ix, tab) in self.tabs.iter_mut().enumerate() {
|
||||
@@ -650,15 +1029,13 @@ impl Tty7App {
|
||||
if f.path != *path {
|
||||
continue;
|
||||
}
|
||||
// Our own save's echo: mtime matches what we just wrote.
|
||||
if mtime.is_some() && mtime == f.disk_mtime {
|
||||
continue;
|
||||
}
|
||||
if f.dirty {
|
||||
f.conflict = true;
|
||||
changed = true;
|
||||
} else {
|
||||
reload.push((tab_ix, ix));
|
||||
match classify_external_change(f.saving.is_some(), f.dirty, f.disk_mtime, mtime) {
|
||||
ExternalChange::Ignore => {}
|
||||
ExternalChange::Conflict => {
|
||||
f.conflict = true;
|
||||
changed = true;
|
||||
}
|
||||
ExternalChange::Reload => reload.push((tab_ix, ix)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,18 +1065,59 @@ impl Tty7App {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&f.path) else {
|
||||
f.dirty = true;
|
||||
f.conflict = false;
|
||||
cx.notify();
|
||||
let target = f.path.clone();
|
||||
let id = f.input.entity_id();
|
||||
// Only the newest reload may install. Two watcher batches can put two
|
||||
// reads in flight, and background completion order is unconstrained —
|
||||
// an older answer landing last would install stale text and mark it
|
||||
// clean, leaving a buffer that does not match disk and never rechecks.
|
||||
f.reload_seq = f.reload_seq.wrapping_add(1);
|
||||
let seq = f.reload_seq;
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
return;
|
||||
};
|
||||
f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok();
|
||||
f.dirty = false;
|
||||
f.conflict = false;
|
||||
let input = f.input.clone();
|
||||
input.update(cx, |input, cx| input.set_value(text, window, cx));
|
||||
cx.notify();
|
||||
HostOps::run_in(
|
||||
host,
|
||||
window,
|
||||
cx,
|
||||
move |h| {
|
||||
// One hop for both, so the mtime belongs to the bytes we read
|
||||
// rather than to whatever the file became in between.
|
||||
let bytes = h.read_file(&target, MAX_FILE_BYTES)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "not valid UTF-8")
|
||||
})?;
|
||||
let mtime = h.stat(&target).ok().and_then(|m| m.mtime);
|
||||
Ok((text, mtime))
|
||||
},
|
||||
move |app, result: std::io::Result<(String, Option<MTime>)>, window, cx| {
|
||||
let Some(f) = app.editor_file_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if f.reload_seq != seq {
|
||||
return; // a newer reload supersedes this answer
|
||||
}
|
||||
let Ok((text, mtime)) = result else {
|
||||
// A vanished (or unreadable) file keeps the buffer and
|
||||
// marks it dirty — saving will recreate it.
|
||||
f.dirty = true;
|
||||
f.conflict = false;
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
f.disk_mtime = mtime;
|
||||
f.dirty = false;
|
||||
f.conflict = false;
|
||||
// The reload replaces the text wholesale, and `set_value`
|
||||
// suppresses the Change event, so `edit_seq` must move by hand
|
||||
// — otherwise a save in flight would look like it still
|
||||
// matched the buffer.
|
||||
f.edit_seq = f.edit_seq.wrapping_add(1);
|
||||
let input = f.input.clone();
|
||||
input.update(cx, |input, cx| input.set_value(text, window, cx));
|
||||
cx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,4 +1472,98 @@ mod tests {
|
||||
assert!(!looks_binary("plain text\nwith lines".as_bytes()));
|
||||
assert!(!looks_binary("中文 UTF-8 内容".as_bytes()));
|
||||
}
|
||||
|
||||
fn t(secs: i64, nanos: u32) -> Option<MTime> {
|
||||
Some(MTime { secs, nanos })
|
||||
}
|
||||
|
||||
/// M2 regression guard (contract §10.5): the save → external-change →
|
||||
/// reload states still decide correctly now that the write is asynchronous.
|
||||
#[test]
|
||||
fn external_changes_are_told_apart_from_our_own_saves() {
|
||||
let ours = t(100, 0);
|
||||
|
||||
// The echo of our own save: same mtime, nothing to do.
|
||||
assert_eq!(
|
||||
classify_external_change(false, false, ours, ours),
|
||||
ExternalChange::Ignore
|
||||
);
|
||||
|
||||
// A real external edit to a clean buffer reloads silently.
|
||||
assert_eq!(
|
||||
classify_external_change(false, false, ours, t(101, 0)),
|
||||
ExternalChange::Reload
|
||||
);
|
||||
|
||||
// The same edit under unsaved work raises the banner instead of
|
||||
// clobbering either side.
|
||||
assert_eq!(
|
||||
classify_external_change(false, true, ours, t(101, 0)),
|
||||
ExternalChange::Conflict
|
||||
);
|
||||
|
||||
// Nanosecond precision is the point of `MTime`: an external write in
|
||||
// the same second as ours must not read as an echo.
|
||||
assert_eq!(
|
||||
classify_external_change(false, false, t(100, 0), t(100, 1)),
|
||||
ExternalChange::Reload
|
||||
);
|
||||
|
||||
// While our own write is in flight, `disk_mtime` still names the old
|
||||
// content — acting on it would reload the file out from under the save.
|
||||
assert_eq!(
|
||||
classify_external_change(true, false, ours, t(101, 0)),
|
||||
ExternalChange::Ignore
|
||||
);
|
||||
|
||||
// A filesystem with no mtime cannot prove an echo, so a change there is
|
||||
// treated as real rather than silently dropped.
|
||||
assert_eq!(
|
||||
classify_external_change(false, false, None, None),
|
||||
ExternalChange::Reload
|
||||
);
|
||||
}
|
||||
|
||||
/// M2 regression guard (contract §1, the ⌘S exemption): the three things
|
||||
/// asynchronous saving has to get right.
|
||||
#[test]
|
||||
fn a_landed_save_only_cleans_a_buffer_that_did_not_move() {
|
||||
// Nothing happened during the write: the buffer is clean.
|
||||
assert_eq!(
|
||||
settle_save(true, 7, 7, false),
|
||||
SaveLanding {
|
||||
clean: true,
|
||||
requeue: false
|
||||
}
|
||||
);
|
||||
|
||||
// The user kept typing: what reached disk is already stale, so the
|
||||
// buffer stays dirty and the amber dot stays up.
|
||||
assert_eq!(
|
||||
settle_save(true, 7, 9, false),
|
||||
SaveLanding {
|
||||
clean: false,
|
||||
requeue: false
|
||||
}
|
||||
);
|
||||
|
||||
// A second ⌘S arrived mid-write: re-issue it so the newest content wins.
|
||||
assert_eq!(
|
||||
settle_save(true, 7, 9, true),
|
||||
SaveLanding {
|
||||
clean: false,
|
||||
requeue: true
|
||||
}
|
||||
);
|
||||
|
||||
// A failed write never cleans and never re-issues — requeueing a path
|
||||
// that cannot be written is an infinite notification loop.
|
||||
assert_eq!(
|
||||
settle_save(false, 7, 7, true),
|
||||
SaveLanding {
|
||||
clean: false,
|
||||
requeue: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-23
@@ -44,6 +44,17 @@ pub(crate) enum DiffLoad {
|
||||
/// when closed). Per-tab: switching tabs hides/restores it, closing the tab
|
||||
/// drops it; only the active tab's overlay is rendered.
|
||||
pub(crate) struct DiffOverlayState {
|
||||
/// The machine the diff is read from — the pane's own host, so an overlay
|
||||
/// opened on a pane whose repository lives elsewhere shows *that*
|
||||
/// repository. Part of the toggle key together with `cwd`: the same path on
|
||||
/// two machines is two different diffs.
|
||||
///
|
||||
/// The id, not the host object: an overlay outlives a reconnect (it is only
|
||||
/// dropped by closing it or its tab), and the object it was opened with
|
||||
/// belongs to the connection that has since been replaced. Every re-probe
|
||||
/// resolves the id afresh, so a reconnected machine's next refresh lands
|
||||
/// instead of failing forever against a dead client.
|
||||
pub(crate) host_id: crate::ui::host_ops::HostId,
|
||||
/// The pane cwd the diff is probed from — the same path the clicked git
|
||||
/// line resolved its status through, so overlay and sidebar agree on the
|
||||
/// repo. Also the toggle key: re-clicking a line with this cwd closes.
|
||||
@@ -72,11 +83,12 @@ impl Tty7App {
|
||||
/// different cwd swaps the overlay's repo in place.
|
||||
pub(crate) fn toggle_diff_overlay(
|
||||
&mut self,
|
||||
host: crate::ui::host_ops::HostId,
|
||||
cwd: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.toggle_diff_overlay_at(cwd, None, window, cx)
|
||||
self.toggle_diff_overlay_at(host, cwd, None, window, cx)
|
||||
}
|
||||
|
||||
/// The same toggle, scoped to one file: opens the overlay showing only
|
||||
@@ -86,6 +98,7 @@ impl Tty7App {
|
||||
/// without the overlay blinking shut and re-probing.
|
||||
pub(crate) fn toggle_diff_overlay_at(
|
||||
&mut self,
|
||||
host: crate::ui::host_ops::HostId,
|
||||
cwd: PathBuf,
|
||||
focus: Option<String>,
|
||||
window: &mut Window,
|
||||
@@ -108,7 +121,7 @@ impl Tty7App {
|
||||
.tabs
|
||||
.get_mut(active)
|
||||
.and_then(|t| t.diff_overlay.as_mut())
|
||||
.filter(|o| o.cwd == cwd)
|
||||
.filter(|o| o.cwd == cwd && o.host_id == host)
|
||||
{
|
||||
// Already open on this repo showing this exact thing, and already on
|
||||
// top — toggle off.
|
||||
@@ -138,6 +151,7 @@ impl Tty7App {
|
||||
};
|
||||
let focus_handle = cx.focus_handle();
|
||||
tab.diff_overlay = Some(DiffOverlayState {
|
||||
host_id: host,
|
||||
cwd,
|
||||
focus_handle: focus_handle.clone(),
|
||||
load: DiffLoad::Loading,
|
||||
@@ -153,9 +167,13 @@ impl Tty7App {
|
||||
/// The file the active tab's overlay is currently scoped to, if any — the
|
||||
/// Changes panel reads it to mark the matching row as selected, so panel and
|
||||
/// overlay can't disagree about what's on screen.
|
||||
pub(crate) fn diff_overlay_focus(&self, cwd: &std::path::Path) -> Option<&str> {
|
||||
pub(crate) fn diff_overlay_focus(
|
||||
&self,
|
||||
host: crate::ui::host_ops::HostId,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<&str> {
|
||||
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
|
||||
(overlay.cwd == cwd).then(|| overlay.focus.as_deref())?
|
||||
(overlay.cwd == cwd && overlay.host_id == host).then_some(overlay.focus.as_deref())?
|
||||
}
|
||||
|
||||
/// Close the active tab's overlay (Esc, ✕, or the toggle) and give focus
|
||||
@@ -188,24 +206,32 @@ impl Tty7App {
|
||||
if overlay.loading {
|
||||
return;
|
||||
}
|
||||
overlay.loading = true;
|
||||
let cwd = overlay.cwd.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.spawn({
|
||||
let cwd = cwd.clone();
|
||||
async move { git_diff::probe(&cwd) }
|
||||
})
|
||||
.await;
|
||||
let _ = this.update(cx, |app, cx| {
|
||||
// Land on every tab whose overlay shows this cwd — the spawning
|
||||
// tab may no longer be active, and sibling tabs on the same repo
|
||||
// are equally stale. A slot closed or swapped to another repo
|
||||
// while we flew is skipped.
|
||||
let id = overlay.host_id;
|
||||
// A machine that is not registered has nothing to probe. Leave `loading`
|
||||
// alone so the overlay keeps the snapshot it has (or its loading state)
|
||||
// and the next trigger tries again — a reconnect re-registers the id.
|
||||
let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, id) else {
|
||||
return;
|
||||
};
|
||||
overlay.loading = true;
|
||||
let probe_cwd = cwd.clone();
|
||||
crate::ui::host_ops::HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
move |h| git_diff::probe(h, &probe_cwd),
|
||||
move |app, result, cx| {
|
||||
// Land on every tab whose overlay shows this repo on this
|
||||
// machine — the spawning tab may no longer be active, and
|
||||
// sibling tabs on the same repo are equally stale. A slot
|
||||
// closed or swapped to another repo while we flew is skipped.
|
||||
let mut landed = false;
|
||||
for tab in app.tabs.iter_mut() {
|
||||
let Some(overlay) = tab.diff_overlay.as_mut().filter(|o| o.cwd == cwd) else {
|
||||
let Some(overlay) = tab
|
||||
.diff_overlay
|
||||
.as_mut()
|
||||
.filter(|o| o.cwd == cwd && o.host_id == id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
overlay.loading = false;
|
||||
@@ -218,9 +244,8 @@ impl Tty7App {
|
||||
if landed {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-probe the open overlay when the shared status cache learned
|
||||
@@ -247,7 +272,7 @@ impl Tty7App {
|
||||
};
|
||||
let Some(status) = cx
|
||||
.try_global::<crate::terminal::git_status::GitStatusCache>()
|
||||
.and_then(|cache| cache.status_for(&overlay.cwd))
|
||||
.and_then(|cache| cache.status_for(overlay.host_id, &overlay.cwd))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
+1042
-449
File diff suppressed because it is too large
Load Diff
+71
-240
@@ -16,10 +16,9 @@ use gpui::{
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::kbd::Kbd;
|
||||
use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::core::session::{SessionPane, SessionTab, WorkspaceId, WorkspaceStore};
|
||||
use crate::core::session::{SessionPane, SessionTab};
|
||||
use crate::ui::app::Tty7App;
|
||||
|
||||
/// The "tty7" logotype in half-block characters. Rendered line-by-line in the
|
||||
@@ -38,9 +37,13 @@ const LOGO_PX: f32 = 20.0;
|
||||
/// The curated shortcuts taught on the home page: (action name, label). A
|
||||
/// deliberate subset — the full table lives in Settings → Keybindings; this is
|
||||
/// a watermark, not documentation.
|
||||
const HOME_SHORTCUTS: [(&str, &str); 6] = [
|
||||
const HOME_SHORTCUTS: [(&str, &str); 7] = [
|
||||
("NewTab", "New Tab"),
|
||||
("ReopenClosedTab", "Reopen Closed Tab"),
|
||||
// The way to another workspace — or another machine — now that this page
|
||||
// no longer lists them. Without this row an empty window says nothing about
|
||||
// where the rest of the user's work went.
|
||||
("ToggleSwitcher", "Switch Workspace"),
|
||||
("TogglePalette", "Command Palette"),
|
||||
("SplitRight", "Split Right"),
|
||||
("SplitDown", "Split Down"),
|
||||
@@ -84,28 +87,19 @@ fn clamp_label(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most closed workspaces to offer on the home page. The picker is a "get back
|
||||
/// to what you were doing" affordance, not a session manager — a long tail of
|
||||
/// months-old workspaces would bury the recent ones and turn the page into a
|
||||
/// wall. The rest stay in `session.json` and reachable from the command palette.
|
||||
const MAX_PICKER_ROWS: usize = 6;
|
||||
|
||||
/// Longest workspace path shown before the front is elided.
|
||||
/// Longest workspace path shown before the front is elided. Named for the
|
||||
/// picker this page used to hold; the switcher inherited both the constant and
|
||||
/// the reason for it.
|
||||
pub(crate) const PICKER_PATH_MAX: usize = 34;
|
||||
|
||||
/// One closed workspace, flattened for rendering. Owned (not a `&Workspace`)
|
||||
/// so collecting it releases the borrow on the global store before the row
|
||||
/// closures capture `cx`.
|
||||
struct PickerRow {
|
||||
id: WorkspaceId,
|
||||
name: String,
|
||||
path: String,
|
||||
panes: usize,
|
||||
when: String,
|
||||
/// Whether any of its panes are still running in the daemon. A stopped
|
||||
/// workspace still lists its panes — they are the *saved* layout, not live
|
||||
/// shells — so the count alone can't say which of the two this is.
|
||||
live: bool,
|
||||
/// Now, in Unix seconds — the clock every "2 minutes ago" in the app is
|
||||
/// measured against. A clock that cannot be read reads as the epoch, which
|
||||
/// [`relative_time`] renders as "just now" rather than as a negative age.
|
||||
pub(crate) fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Human-readable age of a workspace's last activity. Coarse on purpose — the
|
||||
@@ -215,12 +209,13 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
|
||||
// Workspaces the user closed earlier. Closing a window detaches its
|
||||
// workspace rather than ending it — the panes keep running in the
|
||||
// daemon — so this list is how they come back. It sits directly under
|
||||
// the logo, above the shortcut watermark: getting back to real work
|
||||
// outranks learning a keybinding.
|
||||
let picker = self.render_workspace_picker(cx);
|
||||
// Nothing in the middle any more. The picker and the "connect to
|
||||
// another machine" wizard both used to live here, and both were
|
||||
// answering the question `ui::switcher` now owns — from the title-bar
|
||||
// chip, which is on screen in *every* window rather than only in an
|
||||
// empty one. Keeping a second copy here would mean two surfaces to keep
|
||||
// in step and two places to learn.
|
||||
let status = self.render_remote_status_strip(cx);
|
||||
|
||||
v_flex()
|
||||
.id("home-page")
|
||||
@@ -241,7 +236,7 @@ impl Tty7App {
|
||||
}
|
||||
}))
|
||||
.child(logo)
|
||||
.children(picker)
|
||||
.children(status)
|
||||
.child(list)
|
||||
// Ease the page in rather than popping it — closing the last tab
|
||||
// should feel like arriving somewhere, not like a glitch.
|
||||
@@ -252,218 +247,54 @@ impl Tty7App {
|
||||
)
|
||||
}
|
||||
|
||||
/// The closed-workspace picker, or `None` when there is nothing to reopen
|
||||
/// (first run, or every workspace is already on screen) — an empty panel
|
||||
/// would just be clutter on a page whose point is calm.
|
||||
fn render_workspace_picker(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// Collect owned rows first: this releases the borrow on the workspace
|
||||
// store before the per-row click handlers capture `cx`.
|
||||
let alive = self.alive_panes_cached();
|
||||
let rows: Vec<PickerRow> = WorkspaceStore::all(cx)
|
||||
.closed_workspaces()
|
||||
.into_iter()
|
||||
.take(MAX_PICKER_ROWS)
|
||||
.map(|w| PickerRow {
|
||||
live: w.pane_ids().iter().any(|id| alive.contains(id)),
|
||||
id: w.id,
|
||||
name: clamp_label(&w.display_name()),
|
||||
path: w
|
||||
.dominant_repo()
|
||||
.or_else(|| w.first_cwd())
|
||||
.map(|p| display_path(&p))
|
||||
.unwrap_or_default(),
|
||||
panes: w.pane_count(),
|
||||
when: relative_time(now, w.last_active),
|
||||
})
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// ----- connect to another machine (design §10) --------------------------
|
||||
|
||||
// Copied out rather than held as a `&Theme`: the rows below hand `cx`
|
||||
// straight to the shared avatar builder, and a live borrow of the theme
|
||||
// would be in its way.
|
||||
let (muted, foreground, popover, border) = {
|
||||
let theme = cx.theme();
|
||||
(
|
||||
theme.muted_foreground,
|
||||
theme.foreground,
|
||||
theme.popover,
|
||||
theme.border,
|
||||
)
|
||||
};
|
||||
// The established popup language: a solid 10px-radius panel with inset
|
||||
// soft-grey pill highlights — no translucency, no saturated accent. The
|
||||
// panel is a popover, so its rows read that ladder's hover rung; the 0.6
|
||||
// alpha this replaces made the fill depend on whatever showed through.
|
||||
let hover_fill = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().popover.hover);
|
||||
|
||||
let mut panel = v_flex()
|
||||
.w(px(360.))
|
||||
.p(px(6.))
|
||||
.gap(px(2.))
|
||||
.rounded(px(10.))
|
||||
.bg(popover)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
// The page behind us spawns a terminal on *any* left click (the
|
||||
// empty window's whole job). Without this, a click meant for a row
|
||||
// bubbles out to that handler, which swaps the home page away
|
||||
// before the row's own `on_click` — mouse *up* — ever fires. The
|
||||
// picker would look like it did nothing but open a stray terminal.
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation());
|
||||
|
||||
for row in rows {
|
||||
let id = row.id;
|
||||
let live = row.live;
|
||||
// The context menu builds outside `cx.listener`, so it reaches the
|
||||
// app the way the tab context menu does — through a weak handle.
|
||||
let menu_app = cx.entity().downgrade();
|
||||
let menu_app2 = menu_app.clone();
|
||||
panel = panel.child(
|
||||
h_flex()
|
||||
.id(("workspace-row", id.element_key() as usize))
|
||||
// Named group so the row's ✕ can reveal itself on hover of
|
||||
// the whole row, not just of the glyph's own few pixels.
|
||||
.group("workspace-row")
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.px(px(10.))
|
||||
.py(px(7.))
|
||||
.rounded(px(6.))
|
||||
.hover(|row| row.bg(hover_fill))
|
||||
.cursor_pointer()
|
||||
// The picker only renders on the home page, so this window
|
||||
// is empty: swap it over in place rather than opening a
|
||||
// second window and stranding this blank one. If the
|
||||
// workspace somehow already has a window, focus that.
|
||||
// ⌘-click opens in a *new* window, plain click swaps this
|
||||
// one over — the same gesture browsers and Finder use, so
|
||||
// the user never has to decide "which container" before
|
||||
// picking what they want to see.
|
||||
.on_click(cx.listener(move |this, ev: &gpui::ClickEvent, window, cx| {
|
||||
if ev.modifiers().platform {
|
||||
crate::ui::windows::open(cx, Some(id));
|
||||
} else {
|
||||
this.reveal_workspace(id, window, cx);
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
// The same monogram badge the title-bar chip and
|
||||
// the workspace menu use, with liveness riding its
|
||||
// corner: a dot means the shells are still running
|
||||
// in the daemon and reopening reattaches to them.
|
||||
// No dot means the layout is all that is left and
|
||||
// reopening spawns fresh — the app's existing
|
||||
// convention that a resting thing is just its mark.
|
||||
.child(crate::ui::tab_strip::workspace_avatar(
|
||||
// Never "current": this page only renders with
|
||||
// zero tabs, so every row in it is a workspace
|
||||
// you are *not* looking at.
|
||||
&row.name, row.live, false, 26., cx,
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(1.))
|
||||
.overflow_hidden()
|
||||
.child(div().text_sm().text_color(foreground).child(row.name))
|
||||
.child(div().text_xs().text_color(muted).child(row.path)),
|
||||
),
|
||||
/// The status strip a remote window wears when it is not attached.
|
||||
///
|
||||
/// Design §10 puts one at the top of the window in every state that is not
|
||||
/// `Attached`, and §17 is why: a window that has lost its machine must keep
|
||||
/// showing what it had and say so, rather than close or empty itself. A
|
||||
/// local window and a healthy remote one say nothing — a permanent "you are
|
||||
/// fine" banner is noise.
|
||||
fn render_remote_status_strip(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let machine = self.remote_machine_label(cx);
|
||||
let status = self.remote_status(cx)?;
|
||||
let message = status.strip_message(&machine)?;
|
||||
// §17: a failure state is a resting state, so it always offers the next
|
||||
// move. The button belongs here and not only on a window with tabs —
|
||||
// this is the *empty* remote window, which is precisely the one with no
|
||||
// other way out.
|
||||
let action = status.action_label();
|
||||
let theme = cx.theme();
|
||||
Some(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px(px(12.))
|
||||
.py(px(6.))
|
||||
.rounded(px(10.))
|
||||
.bg(theme.popover)
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(gpui_component::Icon::new(IconName::Globe))
|
||||
.child(message)
|
||||
.when_some(action, |this, label| {
|
||||
this.child(
|
||||
Button::new("home-remote-status-action")
|
||||
.label(label)
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, _window, cx| this.remote_retry(cx)))
|
||||
// The page spawns a terminal on any bare left click.
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.items_end()
|
||||
.gap(px(1.))
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(if row.panes == 1 {
|
||||
"1 pane".to_string()
|
||||
} else {
|
||||
format!("{} panes", row.panes)
|
||||
})
|
||||
.child(row.when),
|
||||
)
|
||||
// One hover action, not a cluster: the sidebar row
|
||||
// — the busiest row in the app — reveals exactly
|
||||
// one and keeps the rest on its right-click menu.
|
||||
// Deleting is the irreversible one, which is
|
||||
// precisely why it hides until aimed at rather than
|
||||
// sitting out in the open; stopping is a click away
|
||||
// on the same row's context menu.
|
||||
.child(
|
||||
div()
|
||||
.invisible()
|
||||
.group_hover("workspace-row", |x| x.visible())
|
||||
// Without this the press also reaches the
|
||||
// row underneath and opens the very
|
||||
// workspace being thrown away.
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
cx.stop_propagation()
|
||||
})
|
||||
.child(
|
||||
Button::new((
|
||||
"workspace-delete",
|
||||
id.element_key() as usize,
|
||||
))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.delete_workspace(id, window, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The rest of the row's actions. A right-click menu is what
|
||||
// every other list in this app uses for its second-tier
|
||||
// actions (see the tab rows), and it works here because the
|
||||
// picker is a page — inside the title-bar workspace menu it
|
||||
// can't be done, since a popup dismisses on any mouse-down
|
||||
// outside its own bounds and would tear itself down before
|
||||
// the nested menu's click ever landed.
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
let app = menu_app.clone();
|
||||
menu.item(
|
||||
PopupMenuItem::new("Stop Workspace")
|
||||
// Nothing to stop on a workspace whose shells
|
||||
// are already gone.
|
||||
.disabled(!live)
|
||||
.on_click(move |_, window, cx| {
|
||||
let _ = app
|
||||
.update(cx, |this, cx| this.stop_workspace(id, window, cx));
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.item(
|
||||
PopupMenuItem::new("Delete Workspace…").on_click({
|
||||
let app = menu_app2.clone();
|
||||
move |_, window, cx| {
|
||||
let _ = app.update(cx, |this, cx| {
|
||||
this.delete_workspace(id, window, cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
Some(panel)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user