diff --git a/CHANGELOG.md b/CHANGELOG.md index f764676..2cd1f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-10-27 +- [host] Old Rust hosts no longer pile up on ssh machines (#76): each connect touches this version's `~/.wraithgrid/bin/wraithgrid-host-` (its date says when it was last used) and deletes other versions unused for 30 days, so two Wraithgrid versions sharing a machine don't copy theirs over again on every connect; a host still running a deleted one keeps running. `command find`, since a fish config can shadow `find` (this machine's does); tested for real in sh and fish (files: src/main/remote-bin.ts, tests/unit/remote-host.test.ts) - [host] Rust host phase 4a (#76, ADR 0004 Accepted): terminal panes run in the Rust host by default on Linux, macOS and Windows. `settings.nodeHost` (Settings → Advanced "Use the Node host", off; `WRAITHGRID_NODE_HOST=1` in tests) replaces `rustHost`: every config saved that as false, so it is dropped on load and everyone moves to Rust; a build without the binary runs the Node host. `bun run test:e2e` builds the Rust host first, so e2e runs the default; `rust-host.spec` checks both ways (files: src/shared/{types,schema}.ts, src/main/app.ts, src/renderer/components/SettingsView.tsx, tests/e2e/{rust-host,machines}.spec.ts, package.json, docs/adr/0004-rust-pty-host.md, docs/CONTEXT.md) - [host] Rust host phase 3b (#76, ADR 0004): Windows builds carry `wraithgrid-host.exe` (`dist:win` builds it). Panes run in a ConPTY of our own (portable-pty quotes every argument, and an npm `.cmd` CLI comes as a ready `cmd.exe /d /s /c "…"` line): file and args quoted the way CreateProcess reads them, a ready line kept as is, stdio cut off from the host's log, the exit code from the process handle (no signals), kill = close the console then `taskkill /T /F` after the grace, as the Node host does. The host serves a named pipe (one instance per client, remote clients refused), checks the lock owner with OpenProcess, gets its token from BCryptGenRandom, and a Ctrl-C hook that can't be set (no console) never stops it; the ssh bridge stays Unix. `host-rs/tests/smoke.rs` starts the built host on every OS, connects over its socket or pipe, runs a pane that prints and one typed into, then shuts it down; CI's Windows job runs it with clippy. Settings → Advanced "Use the Rust host" shows on every OS (files: host-rs/src/{pty,main,server}.rs, host-rs/Cargo.{toml,lock}, host-rs/tests/smoke.rs, src/main/app.ts, src/renderer/components/SettingsView.tsx, package.json, electron-builder.yml, .github/workflows/build.yml, docs/CONTEXT.md) - [host] Rust host phase 3a (#76, ADR 0004): macOS builds carry it too: `bun run dist:mac` builds one universal (arm64 + x64) `wraithgrid-host` (`scripts/build-mac-host.sh`, lipo) bundled in both apps and ad-hoc signed with them; Settings → Advanced "Use the Rust host" shows on macOS; CI's macOS job runs `cargo test` and the host parity suite. Windows is phase 3b: portable-pty always quotes arguments, so the ready `cmd.exe /d /s /c "…"` line of an npm `.cmd` CLI needs a ConPTY spawn of our own (files: scripts/build-mac-host.sh, package.json, electron-builder.yml, .github/workflows/build.yml, src/main/app.ts, src/renderer/components/SettingsView.tsx, docs/CONTEXT.md) diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index a35bc25..7eb2500 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -77,6 +77,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/renderer/lib/{ansi,ids,scheduling}.ts`, `src/renderer/layout/presets.ts` ## Known gotchas +- Commands run on an ssh machine go through the user's own shell and config (sh or fish): call tools as `command find` etc., since a fish function can shadow them (remote-bin.ts). - Unpackaged (e2e), `app.getVersion()` is Electron's version (44.x), so the remote Rust host is copied as `wraithgrid-host-44.x`. A process the host starts detached must not keep inherited fds (Playwright's pipes, sshd's): app.close/ssh then never finish (host-start.ts masks them; bridge.rs marks them CLOEXEC). - e2e fixtures with claude panes saved as terminals (`view` omitted = terminal) turn into chats on load unless `settings.chatOnlyMigrated: true` (#110); suites driving claude's own screen (background, cli, keys, mods, smoke, git-palette) set it. - A reattached screen goes out as several `replay` data frames (`chunkReplay`, 512K chars each, #134), never one over `MAX_FRAME` (4 MB, a client drops the link); clients must treat consecutive replay frames as one screen (`wraithgrid attach` clears only before the first). diff --git a/docs/TODO.md b/docs/TODO.md index b193755..2c18baf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,7 @@ # TODO ## Now -- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows done on feat/rust-host-windows-20261027 (only CI's smoke test: never run in the real app on Windows, nor with a real npm claude.cmd); 4a default on done (fallback: "Use the Node host"); 4b (remove the Node host and node-pty) after a release on 4a, ask the user first; phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed +- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows done on feat/rust-host-windows-20261027 (only CI's smoke test: never run in the real app on Windows, nor with a real npm claude.cmd); 4a default on done (fallback: "Use the Node host"); 4b (remove the Node host and node-pty) after a release on 4a, ask the user first; phase 5 arm64 binary never run on real arm64 - CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun - Audit (2026-10-26) bugs: all fixed (#135–#138 PR #148, #139 PR #150, #140 PR #151). Not seen live: #138 cancelled pkexec prompt, #140 renderer-crash reattach, codex cancel/stop paths, agy first-start effort - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) diff --git a/src/main/remote-bin.ts b/src/main/remote-bin.ts index 23b2727..32b59e5 100644 --- a/src/main/remote-bin.ts +++ b/src/main/remote-bin.ts @@ -22,13 +22,22 @@ export function remoteTarget(uname: string): string | null { export const remoteBinPath = (version: string): string => `.wraithgrid/bin/wraithgrid-host-${version}` +/** Other versions unused this long are deleted from a machine; 0 days would make two + * Wraithgrid versions that share one machine copy theirs over again on every connect. */ +export const UNUSED_DAYS = 30 + /** * POSIX sh and fish both run these: plain commands joined by `;` and `&&`. A binary counts * only once it ran there (`--version`), so one that can't run falls back instead of failing. + * Found, it is touched (its date says when it was last used), and other versions unused for + * UNUSED_DAYS go; a host still running one keeps running, since Linux keeps a deleted file + * open for it. */ export const preflightCommand = (version: string): string => { const bin = remoteBinPath(version) - return `uname -sm; test -x ${bin} && ${bin} --version >/dev/null && echo ${PRESENT}` + // `command`: a shell function named find (a fish config's) must not stand in for it. + const sweep = `command find .wraithgrid/bin -maxdepth 1 -type f -name 'wraithgrid-host-*' ! -name 'wraithgrid-host-${version}' -mtime +${UNUSED_DAYS} -delete 2>/dev/null` + return `uname -sm; test -x ${bin} && ${bin} --version >/dev/null && touch ${bin} && echo ${PRESENT}; ${sweep}` } export const uploadCommand = (version: string): string => { diff --git a/tests/unit/remote-host.test.ts b/tests/unit/remote-host.test.ts index f9b6886..e1a67a5 100644 --- a/tests/unit/remote-host.test.ts +++ b/tests/unit/remote-host.test.ts @@ -1,3 +1,15 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { describe, expect, it } from 'vitest' import { applyEnvPlan, buildPaneEnv, paneEnvPlan } from '../../src/main/pane-env' import { @@ -73,6 +85,36 @@ describe('the Rust host on a remote machine', () => { expect(uploadCommand('1.2.3')).toMatch(/--version >\/dev\/null && mv -f/) }) + // Run for real by the shells a machine may have: sh, and fish when it is installed here. + const shells = ['sh', 'fish'].filter( + (sh) => spawnSync('sh', ['-c', `command -v ${sh}`]).status === 0 + ) + it.each(shells)('in %s: marks the version in use and deletes others unused for 30 days', (sh) => { + const home = mkdtempSync(path.join(os.tmpdir(), 'wg-remote-')) + try { + const dir = path.join(home, '.wraithgrid/bin') + mkdirSync(dir, { recursive: true }) + const day = 86_400_000 + const file = (name: string, ageDays: number): string => { + const f = path.join(dir, name) + writeFileSync(f, '#!/bin/sh\nexit 0\n', { mode: 0o700 }) + const t = new Date(Date.now() - ageDays * day) + utimesSync(f, t, t) + return f + } + const current = file(path.basename(remoteBinPath('2.0.0')), 90) + const stale = file(path.basename(remoteBinPath('1.0.0')), 40) + const recent = file(path.basename(remoteBinPath('1.9.0')), 5) + const out = spawnSync(sh, ['-c', preflightCommand('2.0.0')], { cwd: home, encoding: 'utf8' }) + expect(out.stdout).toContain('__WRAITHGRID_HOST_PRESENT__') + expect(existsSync(stale)).toBe(false) + expect(existsSync(recent)).toBe(true) + expect(Date.now() - statSync(current).mtimeMs).toBeLessThan(60_000) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + const deps = (stdout: string, binary: Buffer | null, uploadCode = 0) => { const runs: { command: string; input?: Buffer }[] = [] return {