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:
l0ng-ai
2026-07-28 10:59:46 +08:00
parent 0030b98faa
commit 208454e202
116 changed files with 41232 additions and 4782 deletions
+55
View File
@@ -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))"
+8 -1
View File
@@ -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}"
+9 -1
View File
@@ -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
+17
View File
@@ -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
+167
View File
@@ -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"
+34
View File
@@ -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
+4
View File
@@ -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"
+109
View File
@@ -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
View File
@@ -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.
+145 -1
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+181
View File
@@ -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
+303
View File
@@ -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());
}
}
+169
View File
@@ -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);
}
}
+178
View File
@@ -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");
}
}
+235
View File
@@ -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);
}
}
+34
View File
@@ -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
+91
View File
@@ -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
+345
View File
@@ -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"
);
}
}
+676
View File
@@ -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(&reg, 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(&reg, ForwardOwner::Pane(7), 0).await;
let ws_guard = push_listener(&reg, 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(&reg, ForwardOwner::Workspace(a), 0).await;
push_listener(&reg, 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
+690
View File
@@ -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"
);
}
}
+738
View File
@@ -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
+23
View File
@@ -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;
+35
View File
@@ -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
+387
View File
@@ -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());
}
}
+198
View File
@@ -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"));
}
+201
View File
@@ -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")
}
+301
View File
@@ -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.
}
+539
View File
@@ -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,
}
}
+430
View File
@@ -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 │
└────────┴────────────────────┴────────────────────┘
│ 现有 transportUDS / loopback TCP,不动
┌─ 本地 daemon ────────────────────────────────────┐
│ 本地 panePTY)· SSH pane · SFTP · 端口转发 │
│ SshManager / PromptBroker / known_hosts / jump │
│ ── 全部现有,远程 workspace 直接复用 ── │
│ RemoteRouter ◄── 新:纯字节转发,不解析 │
└────────┬─────────────────────────────────────────┘
│ SSH:每条流一条 channel
│ 首选 direct-streamlocal → 远程 daemon.sock
│ 回退 session channel + exec tty7-server --stdio
│ WSLwsl.exe 子进程的 stdin/stdout
┌─ 远程 tty7-serverheadless,无 gpui ──────────┐
│ pane registryDaemonPane,现有代码原样搬) │
│ 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 无 gpuiprotocol / 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 --daemonsetsid 脱离)→ 重试
```
第 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`;目录 0700socket 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 信息重新 spawnagent 走现有的 `--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
+122
View File
@@ -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.
+4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+34 -161
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22 -78
View File
@@ -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"));
}
}
+15
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+473
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+664 -186
View File
File diff suppressed because it is too large Load Diff
+656 -144
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+71 -240
View File
@@ -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