diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f60d9fe..ffec1b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,6 +99,14 @@ jobs: cargo test --locked --manifest-path host-rs/Cargo.toml bun run test:host bash scripts/build-mac-host.sh + # Windows (phase 3b): clippy for the Windows code, and the tests, whose smoke test runs + # the built host over its named pipe with ConPTY panes (the parity suite is Unix only). + - name: Rust host on Windows + if: runner.os == 'Windows' + working-directory: host-rs + run: | + cargo clippy --all-targets --locked -- -D warnings + cargo test --locked - if: inputs.package run: bun run ${{ matrix.dist }} env: diff --git a/CHANGELOG.md b/CHANGELOG.md index d387d4d..97ac349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-10-27 +- [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) - [chat] A ↑ recall or a `/` pick no longer moves the caret a frame later (cause of the intermittent `chat-history.spec.ts` CI failures, e.g. `second promptline one`): the frame could land between a select-all and the typing that replaces it, so the text was added instead; the caret is now placed in a layout effect right after the text is in the box (files: src/renderer/components/chat/Composer.tsx) - [chat] A sideways swipe over the chat no longer pauses follow (the cause of the intermittent `chat.spec.ts:299` CI failure): Chromium sometimes scrolls the chat by the swipe's few px of vertical noise, and that upward move read as the reader going back; for 150 ms after a mostly sideways wheel the chat's own upward scroll is ignored (files: src/renderer/components/chat/ChatView.tsx) diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 6541602..531dd37 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -27,7 +27,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/main/host-start.ts`: start a detached host (fd masking) + socket connect; used by app and bridge `src/main/pane-router.ts`: routes pane ops to the local host or a machine's (by workspace `machineId`) - `src/main/spawn-resolve.ts`: pane spec → exact process (binary, args, cwd, env); local in ipc, remote in the bridge `src/main/ssh.ts`: `~/.ssh/config` hosts, ssh args, ssh child as a duplex stream - `src/main/ipc.ts`: all IPC handlers; validates every payload with zod contracts `src/main/pane-host.ts`: GUI side of the pty host: find/start it, hello + token, create/attach/write/kill, version mismatch -- `host-rs/`: the Rust pty host (ADR 0004; phase 2/3a: Settings → Advanced "Use the Rust host", off by default, Linux and macOS (Windows: phase 3b); `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio `, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`) +- `host-rs/`: the Rust pty host (ADR 0004; phase 2/3: Settings → Advanced "Use the Rust host", off by default, Linux, macOS and Windows (own ConPTY spawn + named pipe there; `host-rs/tests/smoke.rs` runs the built host on every OS); `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio `, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`) - `src/host/`: background pty host (ADR 0001), built as `out/main/host.js`, never imports electron: `index.ts` entry (lock, token, socket), `server.ts` panes + clients, `protocol.ts` NDJSON frames (zod), `paths.ts` socket/token paths per user-data dir, `screen.ts` headless xterm + serialize, `fds.ts` inherited-fd masking, `pty-manager.ts` spawns/kills pane processes (per-OS KillPolicy) `src/main/chat/`: chat panes (ADR 0002): `sessions.ts` runs one headless CLI per pane (pipes, JSON lines), `adapter.ts` registry, `claude.ts` stream-json ↔ ChatEvent (+ approvals), `agy.ts` agy's stream-json (no approvals/interrupt: `controls` event → the view restarts it), `codex.ts` `codex app-server` JSON-RPC, `images.ts` pasted images → temp files, `spawn.ts` masked fds/process group, `attach.ts` files from the attach picker, `transcript.ts` copies a conversation to another account's dir (claude transcript, or codex rollout under `sessions/YYYY/MM/DD`, #60) (switch account, #29); add a CLI = one adapter + `chat: true` in agent-clis - `src/main/login.ts`: sign-in dialog backend, per CLI (`LOGIN_FLOWS`): claude `auth login` + `auth status --json`; codex `login --device-auth` (one-time code) + `login status`; `DIALOG_LOGIN_CLIS` in agent-clis says who gets the dialog `src/main/clipboard-image.ts`: clipboard image → temp file → text to paste (path, or `@path` for gemini/agy) - `src/main/pane-env.ts`: builds a pane's env (per-CLI config dir var for agent panes; mod folders as `CLAUDE_CODE_PLUGIN_DIRS` for claude; strips the launching terminal's vars; UTF-8 locale fallback) `src/main/config-store.ts`: config.json load/save, atomic write, bad-file quarantine diff --git a/docs/TODO.md b/docs/TODO.md index 2c5d75c..67e9715 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 next (own ConPTY spawn with a raw command line, named pipe, taskkill tree, Ctrl-C), then 4 (default on, drop node-pty); 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); then 4 (default on, drop node-pty); phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed - 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/electron-builder.yml b/electron-builder.yml index c01deeb..26d3145 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -105,6 +105,11 @@ win: arch: - x64 icon: build/icon.png + # The Rust background host (ADR 0004 phase 3b), off unless Settings → Advanced turns it on; + # `bun run dist:win` builds it first. + extraResources: + - from: host-rs/target/release/wraithgrid-host.exe + to: wraithgrid-host.exe nsis: oneClick: false perMachine: false diff --git a/host-rs/Cargo.lock b/host-rs/Cargo.lock index 7b2d8fa..9427b0c 100644 --- a/host-rs/Cargo.lock +++ b/host-rs/Cargo.lock @@ -531,6 +531,7 @@ dependencies = [ "sha2", "tokio", "vt100", + "windows-sys", ] [[package]] diff --git a/host-rs/Cargo.toml b/host-rs/Cargo.toml index ee5435b..5b1be3a 100644 --- a/host-rs/Cargo.toml +++ b/host-rs/Cargo.toml @@ -19,6 +19,17 @@ sha2 = "0.11.1" tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros", "signal", "io-std"] } vt100 = "0.18.1" +# Windows (ADR 0004 phase 3b): our own ConPTY spawn, the lock's liveness check and the token. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Cryptography", + "Win32_System_Console", + "Win32_System_Pipes", + "Win32_System_Threading", +] } + [profile.release] lto = true codegen-units = 1 diff --git a/host-rs/src/main.rs b/host-rs/src/main.rs index b170470..fbed46d 100644 --- a/host-rs/src/main.rs +++ b/host-rs/src/main.rs @@ -1,9 +1,12 @@ //! Wraithgrid's background pty host in Rust (ADR 0004), a drop-in for `out/main/host.js` //! (ADR 0001): started detached by the GUI with its settings as JSON in `WRAITHGRID_HOST`, -//! it takes the same lock, writes the same token and serves the same frames on the same socket. -//! `wraithgrid-host stdio ` is the ssh end of a remote machine instead (bridge.rs). +//! it takes the same lock, writes the same token and serves the same frames on the same socket +//! (a named pipe on Windows). `wraithgrid-host stdio ` is the ssh end of a remote +//! machine instead (bridge.rs, Unix only: remote machines are Linux). +#[cfg(unix)] mod bridge; +#[cfg(unix)] mod plan; mod protocol; mod pty; @@ -13,13 +16,10 @@ mod utf8; use std::fs; use std::io::Write; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::PathBuf; -use std::sync::{Arc, OnceLock}; +use std::sync::OnceLock; use serde_json::Value; -use tokio::net::UnixListener; -use tokio::signal::unix::{signal, SignalKind}; struct Paths { dir: PathBuf, @@ -46,6 +46,7 @@ fn quiet_unlink(p: &PathBuf) { /// Leaves nothing behind on a clean exit; after a failure the log stays for a bug report. fn exit(code: i32) -> ! { if let Some(p) = PATHS.get() { + #[cfg(unix)] quiet_unlink(&p.socket); quiet_unlink(&p.token); quiet_unlink(&p.lock); @@ -58,29 +59,32 @@ fn exit(code: i32) -> ! { std::process::exit(code) } -fn alive(pid: i32) -> bool { - // SAFETY: kill with signal 0 only checks the pid. - let r = unsafe { libc::kill(pid, 0) }; - r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +/// A new file only we can read (0600 on Unix; the user's own profile dir on Windows). +fn private_file(create_new: bool) -> fs::OpenOptions { + let mut o = fs::OpenOptions::new(); + o.write(true); + if create_new { + o.create_new(true); + } else { + o.create(true).truncate(true); + } + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600); + o } /// One host per socket: an exclusive lock file with our pid; a dead owner's lock is taken over. fn take_lock(file: &PathBuf) -> std::io::Result { let me = std::process::id() as i32; for _ in 0..2 { - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(file) - { + match private_file(true).open(file) { Ok(mut f) => { f.write_all(me.to_string().as_bytes())?; return Ok(true); } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { let owner: i32 = fs::read_to_string(file)?.trim().parse().unwrap_or(0); - if owner != 0 && owner != me && alive(owner) { + if owner != 0 && owner != me && os::alive(owner) { return Ok(false); } fs::remove_file(file)?; @@ -91,53 +95,10 @@ fn take_lock(file: &PathBuf) -> std::io::Result { Ok(false) } -/// The host dir is user-only: one that already exists must be ours, a real directory and -/// closed to group/other (in a shared /tmp another user could have made it first). -pub(crate) fn ensure_dir(dir: &PathBuf) -> Result<(), String> { - fs::create_dir_all(dir).map_err(|e| e.to_string())?; - let st = fs::symlink_metadata(dir).map_err(|e| e.to_string())?; - if !st.is_dir() { - return Err(format!("{} is not a directory", dir.display())); - } - // SAFETY: getuid cannot fail. - if st.uid() != unsafe { libc::getuid() } { - return Err(format!("{} belongs to another user", dir.display())); - } - if st.mode() & 0o077 != 0 { - // A dir we just created with the default umask: close it. - fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?; - let st = fs::symlink_metadata(dir).map_err(|e| e.to_string())?; - if st.mode() & 0o077 != 0 { - return Err(format!("{} is open to other users", dir.display())); - } - } - Ok(()) -} - -/// fds 3..=up_to that are /dev/null copies the GUI used to mask its own (src/host/fds.ts). -fn close_masked_fds(up_to: i32) { - let Ok(null) = fs::metadata("/dev/null") else { - return; - }; - let null_dev = null.rdev(); - for fd in 3..=up_to { - // SAFETY: fstat/close on fds we may own; errors mean "not open". - unsafe { - let mut st: libc::stat = std::mem::zeroed(); - if libc::fstat(fd, &mut st) == 0 - && (st.st_mode & libc::S_IFMT) == libc::S_IFCHR - && st.st_rdev as u64 == null_dev - { - libc::close(fd); - } - } - } -} +pub(crate) use os::ensure_dir; fn random_token() -> std::io::Result { - let mut bytes = [0u8; 32]; - fs::File::open("/dev/urandom") - .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))?; + let bytes = os::random_bytes()?; Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) } @@ -165,7 +126,13 @@ fn main() { return; } if argv.get(1).map(String::as_str) == Some("stdio") { + #[cfg(unix)] std::process::exit(bridge::main(argv.get(2).cloned().unwrap_or_default())); + #[cfg(not(unix))] + { + eprintln!("wraithgrid-host stdio: remote machines run Linux"); + std::process::exit(2); + } } let Some((paths, app_version, masked)) = settings() else { eprintln!("wraithgrid-host: started without settings"); @@ -174,9 +141,7 @@ fn main() { // Pane envs are built by the GUI; nothing of the host's own launch leaks into them anyway. std::env::remove_var("WRAITHGRID_HOST"); std::env::remove_var("ELECTRON_RUN_AS_NODE"); - if masked > 0 { - close_masked_fds(masked); - } + os::close_masked_fds(masked); let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() @@ -198,23 +163,20 @@ async fn run(paths: Paths, app_version: String) -> Result<(), String> { std::process::exit(0); } // The lock is ours, so any socket file left is from a host that died. + #[cfg(unix)] quiet_unlink(&paths.socket); let token = random_token().map_err(|e| e.to_string())?; let tmp = paths .token .with_extension(format!("token.{}", std::process::id())); - fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) + private_file(false) .open(&tmp) .and_then(|mut f| f.write_all(token.as_bytes())) .map_err(|e| e.to_string())?; fs::rename(&tmp, &paths.token).map_err(|e| e.to_string())?; - let listener = match UnixListener::bind(&paths.socket) { + let listener = match os::Listener::bind(&paths.socket) { Ok(l) => l, Err(e) => { let _ = PATHS.set(paths); @@ -222,7 +184,6 @@ async fn run(paths: Paths, app_version: String) -> Result<(), String> { exit(1) } }; - let _ = fs::set_permissions(&paths.socket, fs::Permissions::from_mode(0o600)); let _ = PATHS.set(paths); let host = server::Host::new(server::Config { @@ -236,20 +197,206 @@ async fn run(paths: Paths, app_version: String) -> Result<(), String> { "host {} listening (app {app_version}, rust)", std::process::id() )); + os::serve(listener, host).await +} + +#[cfg(unix)] +mod os { + use std::fs; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::path::Path; + use std::sync::Arc; + + use tokio::net::UnixListener; + use tokio::signal::unix::{signal, SignalKind}; + + use crate::{exit, log, server}; + + pub fn alive(pid: i32) -> bool { + // SAFETY: kill with signal 0 only checks the pid. + let r = unsafe { libc::kill(pid, 0) }; + r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + } + + /// The host dir is user-only: one that already exists must be ours, a real directory and + /// closed to group/other (in a shared /tmp another user could have made it first). + pub(crate) fn ensure_dir(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let st = fs::symlink_metadata(dir).map_err(|e| e.to_string())?; + if !st.is_dir() { + return Err(format!("{} is not a directory", dir.display())); + } + // SAFETY: getuid cannot fail. + if st.uid() != unsafe { libc::getuid() } { + return Err(format!("{} belongs to another user", dir.display())); + } + if st.mode() & 0o077 != 0 { + // A dir we just created with the default umask: close it. + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + let st = fs::symlink_metadata(dir).map_err(|e| e.to_string())?; + if st.mode() & 0o077 != 0 { + return Err(format!("{} is open to other users", dir.display())); + } + } + Ok(()) + } + + /// fds 3..=up_to that are /dev/null copies the GUI used to mask its own (src/host/fds.ts). + pub fn close_masked_fds(up_to: i32) { + if up_to <= 0 { + return; + } + let Ok(null) = fs::metadata("/dev/null") else { + return; + }; + let null_dev = null.rdev(); + for fd in 3..=up_to { + // SAFETY: fstat/close on fds we may own; errors mean "not open". + unsafe { + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstat(fd, &mut st) == 0 + && (st.st_mode & libc::S_IFMT) == libc::S_IFCHR + && st.st_rdev as u64 == null_dev + { + libc::close(fd); + } + } + } + } + + pub fn random_bytes() -> std::io::Result<[u8; 32]> { + let mut bytes = [0u8; 32]; + fs::File::open("/dev/urandom") + .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))?; + Ok(bytes) + } + + pub struct Listener(UnixListener); + + impl Listener { + pub fn bind(path: &Path) -> std::io::Result { + let l = UnixListener::bind(path)?; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + Ok(Self(l)) + } + } + + pub async fn serve(listener: Listener, host: Arc) -> Result<(), String> { + let mut term = signal(SignalKind::terminate()).map_err(|e| e.to_string())?; + let mut int = signal(SignalKind::interrupt()).map_err(|e| e.to_string())?; + // Detached from any terminal; a hang-up is not ours to act on. + let mut hup = signal(SignalKind::hangup()).map_err(|e| e.to_string())?; + loop { + tokio::select! { + conn = listener.0.accept() => match conn { + Ok((stream, _)) => { tokio::spawn(Arc::clone(&host).accept(stream)); } + Err(e) => log(&format!("accept failed: {e}")), + }, + _ = term.recv() => { host.stop_all().await; exit(0); } + _ = int.recv() => { host.stop_all().await; exit(0); } + _ = hup.recv() => {} + } + } + } +} + +#[cfg(windows)] +mod os { + use std::fs; + use std::path::Path; + use std::sync::Arc; + + use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::Security::Cryptography::{ + BCryptGenRandom, BCRYPT_USE_SYSTEM_PREFERRED_RNG, + }; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + use crate::{exit, log, server}; - let mut term = signal(SignalKind::terminate()).map_err(|e| e.to_string())?; - let mut int = signal(SignalKind::interrupt()).map_err(|e| e.to_string())?; - // Detached from any terminal; a hang-up is not ours to act on. - let mut hup = signal(SignalKind::hangup()).map_err(|e| e.to_string())?; - loop { - tokio::select! { - conn = listener.accept() => match conn { - Ok((stream, _)) => { tokio::spawn(Arc::clone(&host).accept(stream)); } - Err(e) => log(&format!("accept failed: {e}")), - }, - _ = term.recv() => { host.stop_all().await; exit(0); } - _ = int.recv() => { host.stop_all().await; exit(0); } - _ = hup.recv() => {} + pub fn alive(pid: i32) -> bool { + // SAFETY: a query-only handle, closed right away. + unsafe { + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32); + if h.is_null() { + // Gone, or not ours to open: a process we can't open isn't a host of ours. + return false; + } + let mut code = 0u32; + let ok = GetExitCodeProcess(h, &mut code) != 0; + CloseHandle(h); + ok && code == STILL_ACTIVE as u32 + } + } + + /// Under the user's own profile (userData\run), which other users can't open. + pub(crate) fn ensure_dir(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string()) + } + + pub fn close_masked_fds(_up_to: i32) {} + + pub fn random_bytes() -> std::io::Result<[u8; 32]> { + let mut bytes = [0u8; 32]; + // SAFETY: fills our own buffer from the system RNG. + let st = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + bytes.as_mut_ptr(), + bytes.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if st < 0 { + return Err(std::io::Error::other(format!( + "BCryptGenRandom failed (0x{st:08x})" + ))); + } + Ok(bytes) + } + + /// The named pipe; each client gets its own instance, and a new one waits for the next. + pub struct Listener { + name: String, + next: NamedPipeServer, + } + + impl Listener { + pub fn bind(path: &Path) -> std::io::Result { + let name = path.to_string_lossy().into_owned(); + // First instance: fails if another process already serves this name. + let next = ServerOptions::new() + .first_pipe_instance(true) + .reject_remote_clients(true) + .create(&name)?; + Ok(Self { name, next }) + } + } + + pub async fn serve(mut listener: Listener, host: Arc) -> Result<(), String> { + loop { + tokio::select! { + conn = listener.next.connect() => { + match conn { + Ok(()) => { + let fresh = ServerOptions::new() + .reject_remote_clients(true) + .create(&listener.name) + .map_err(|e| e.to_string())?; + let stream = std::mem::replace(&mut listener.next, fresh); + tokio::spawn(Arc::clone(&host).accept(stream)); + } + Err(e) => log(&format!("accept failed: {e}")), + } + } + // Started detached, with no console: a Ctrl-C hook that can't be set only + // turns this branch off, never stops the host. + Ok(()) = tokio::signal::ctrl_c() => { host.stop_all().await; exit(0); } + } } } } diff --git a/host-rs/src/pty.rs b/host-rs/src/pty.rs index 0ebc88c..5d2bf70 100644 --- a/host-rs/src/pty.rs +++ b/host-rs/src/pty.rs @@ -1,15 +1,16 @@ -//! Pane processes, as `src/host/pty-manager.ts` runs them with node-pty: one pty and process -//! group per pane, output coalesced (16 ms, 256 KB) and decoded as UTF-8, SIGHUP to the group -//! on kill, SIGKILL after the grace period, then a sweep of the group. +//! Pane processes, as `src/host/pty-manager.ts` runs them with node-pty: one pty per pane, +//! output coalesced (16 ms, 256 KB) and decoded as UTF-8. Unix: a process group per pane +//! (portable-pty), SIGHUP to the group on kill, SIGKILL after the grace period, then a sweep. +//! Windows: a ConPTY of our own (portable-pty quotes every argument, and an npm `.cmd` CLI +//! comes as a ready `cmd.exe` command line), closed on kill, `taskkill /T /F` after the grace. use std::io::{Read, Write}; use std::sync::mpsc as std_mpsc; use std::time::Duration; -use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; use tokio::sync::{mpsc, watch}; -use crate::protocol::{Args, SpawnRequest}; +use crate::protocol::SpawnRequest; use crate::utf8::Utf8Stream; pub const FLUSH: Duration = Duration::from_millis(16); @@ -33,10 +34,20 @@ pub enum PtyEvent { }, } +/// A started process, before the host wires its pipes: what each platform's spawn returns. +struct Started { + pid: i32, + console: imp::Console, + reader: Box, + writer: Box, + /// Blocks until the process ends: (exitCode, signal) as node-pty reports them. + wait: Box (i64, Option) + Send>, +} + /// A running pane process. Dropping it closes the pty; `kill` stops the process tree. pub struct Pty { pub pid: i32, - master: Box, + console: imp::Console, input: std_mpsc::Sender>, /// Becomes true once the process exited (and was reaped). exited: watch::Receiver, @@ -48,38 +59,23 @@ impl Pty { } pub fn resize(&self, cols: u16, rows: u16) { - let _ = self.master.resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }); + self.console.resize(cols, rows); } - /// SIGHUP to the group now, SIGKILL after the grace period unless it exited; once the - /// leader is gone, anything left in its group is killed too. Resolves when done. + /// Asks the process to end, then forces it after the grace period. Resolves when done. pub async fn kill(self) { - let pid = self.pid; - let mut exited = self.exited.clone(); - signal_group(pid, libc::SIGHUP, false); - let gone = tokio::time::timeout(KILL_GRACE, exited.wait_for(|e| *e)).await; - if gone.is_ok() { - signal_group(pid, libc::SIGKILL, true); - } else { - signal_group(pid, libc::SIGKILL, false); - } - drop(self); - } -} - -/// node-pty's posixKillPolicy: the group first; the leader alone only if `group_only` is off. -fn signal_group(pid: i32, sig: i32, group_only: bool) { - // SAFETY: plain kill(2) calls on a pid we started. - unsafe { - if libc::kill(-pid, sig) == 0 || group_only { - return; - } - libc::kill(pid, sig); + let Pty { + pid, + console, + mut exited, + .. + } = self; + let console = console.soft_kill(pid); + let gone = tokio::time::timeout(KILL_GRACE, exited.wait_for(|e| *e)) + .await + .is_ok(); + imp::hard_kill(pid, gone); + drop(console); } } @@ -89,39 +85,14 @@ pub fn spawn( gen: u64, events: mpsc::UnboundedSender, ) -> Result { - let system = native_pty_system(); - let pair = system - .openpty(PtySize { - rows: req.rows, - cols: req.cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| e.to_string())?; - - let mut cmd = CommandBuilder::new(&req.file); - match &req.args { - Args::List(args) => cmd.args(args), - Args::Line(line) => cmd.arg(line), - } - cmd.cwd(&req.cwd); - // The GUI builds the whole environment (pane-env.ts strips the terminal's own variables); - // like node-pty with an explicit env, only TERM is set here. - cmd.env_clear(); - for (k, v) in &req.env { - cmd.env(k, v); - } - cmd.env("TERM", "xterm-256color"); - - let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; - drop(pair.slave); - let pid = child.process_id().ok_or("the process has no pid")? as i32; - // The host reaps the process itself (waitpid) to report signals as numbers; keep the - // portable-pty handle alive without waiting on it. - std::mem::forget(child); + let Started { + pid, + console, + reader, + mut writer, + wait, + } = imp::start(req)?; - let reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?; - let mut writer = pair.master.take_writer().map_err(|e| e.to_string())?; let (input, input_rx) = std_mpsc::channel::>(); std::thread::spawn(move || { for chunk in input_rx { @@ -140,8 +111,7 @@ pub fn spawn( std::thread::spawn(move || read_loop(reader, chunks_tx)); let (status_tx, status_rx) = tokio::sync::oneshot::channel::<(i64, Option)>(); std::thread::spawn(move || { - let status = reap(pid); - let _ = status_tx.send(status); + let _ = status_tx.send(wait()); }); tokio::spawn(pump( req.pane_id.clone(), @@ -154,7 +124,7 @@ pub fn spawn( Ok(Pty { pid, - master: pair.master, + console, input, exited, }) @@ -174,27 +144,6 @@ fn read_loop(mut reader: Box, out: mpsc::UnboundedSender (i64, Option) { - let mut status = 0; - loop { - // SAFETY: waitpid on our own child. - let r = unsafe { libc::waitpid(pid, &mut status, 0) }; - if r == pid { - break; - } - if r == -1 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { - return (0, Some(0)); - } - } - if libc::WIFSIGNALED(status) { - (0, Some(i64::from(libc::WTERMSIG(status)))) - } else { - (i64::from(libc::WEXITSTATUS(status)), Some(0)) - } -} - /// Coalesces output into flushes of up to 16 ms or 256 KB, then reports the exit after the /// last output, as node-pty's onExit follows a final flush. async fn pump( @@ -237,7 +186,7 @@ async fn pump( deadline = Some(tokio::time::Instant::now() + FLUSH); } } - // The pty closed; the exit status comes from the reaper. + // The pty closed; the exit status comes from the waiter. None => { pending.push_str(&decoder.finish()); break status.await.unwrap_or((0, Some(0))); @@ -248,7 +197,8 @@ async fn pump( deadline = None; } s = &mut status => { - // Output still queued from before the exit belongs to this run. + // Output still queued from before the exit belongs to this run (a ConPTY keeps + // its pipe open until it is closed, so this is also how Windows output ends). let grace = tokio::time::Instant::now() + Duration::from_millis(50); while let Ok(Some(bytes)) = tokio::time::timeout_at(grace, chunks.recv()).await { pending.push_str(&decoder.push(&bytes)); @@ -267,3 +217,392 @@ async fn pump( signal: result.1, }); } + +#[cfg(unix)] +mod imp { + use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; + + use super::Started; + use crate::protocol::{Args, SpawnRequest}; + + pub struct Console(Box); + + impl Console { + pub fn resize(&self, cols: u16, rows: u16) { + let _ = self.0.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + + /// SIGHUP to the group; the pty stays open until the process is gone. + pub fn soft_kill(self, pid: i32) -> Self { + signal_group(pid, libc::SIGHUP, false); + self + } + } + + /// SIGKILL to the group, or (once the leader is gone) a sweep of what is left in it. + pub fn hard_kill(pid: i32, leader_gone: bool) { + signal_group(pid, libc::SIGKILL, leader_gone); + } + + /// node-pty's posixKillPolicy: the group first; the leader alone only if `group_only` is off. + fn signal_group(pid: i32, sig: i32, group_only: bool) { + // SAFETY: plain kill(2) calls on a pid we started. + unsafe { + if libc::kill(-pid, sig) == 0 || group_only { + return; + } + libc::kill(pid, sig); + } + } + + pub(super) fn start(req: &SpawnRequest) -> Result { + let pair = native_pty_system() + .openpty(PtySize { + rows: req.rows, + cols: req.cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| e.to_string())?; + + let mut cmd = CommandBuilder::new(&req.file); + match &req.args { + Args::List(args) => cmd.args(args), + Args::Line(line) => cmd.arg(line), + } + cmd.cwd(&req.cwd); + // The GUI builds the whole environment (pane-env.ts strips the terminal's own + // variables); like node-pty with an explicit env, only TERM is set here. + cmd.env_clear(); + for (k, v) in &req.env { + cmd.env(k, v); + } + cmd.env("TERM", "xterm-256color"); + + let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; + drop(pair.slave); + let pid = child.process_id().ok_or("the process has no pid")? as i32; + // The host reaps the process itself (waitpid) to report signals as numbers; keep the + // portable-pty handle alive without waiting on it. + std::mem::forget(child); + + let reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?; + let writer = pair.master.take_writer().map_err(|e| e.to_string())?; + Ok(Started { + pid, + console: Console(pair.master), + reader, + writer, + wait: Box::new(move || reap(pid)), + }) + } + + /// Waits for the process like node-pty: (exitCode, signal); a normal exit has signal 0, a + /// signal gives exitCode 0. + fn reap(pid: i32) -> (i64, Option) { + let mut status = 0; + loop { + // SAFETY: waitpid on our own child. + let r = unsafe { libc::waitpid(pid, &mut status, 0) }; + if r == pid { + break; + } + if r == -1 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return (0, Some(0)); + } + } + if libc::WIFSIGNALED(status) { + (0, Some(i64::from(libc::WTERMSIG(status)))) + } else { + (i64::from(libc::WEXITSTATUS(status)), Some(0)) + } + } +} + +#[cfg(windows)] +mod imp { + use std::ffi::OsStr; + use std::fs::File; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use std::os::windows::process::CommandExt; + use std::ptr::{null, null_mut}; + use std::sync::Arc; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{ + ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON, + }; + use windows_sys::Win32::System::Pipes::CreatePipe; + use windows_sys::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, + InitializeProcThreadAttributeList, UpdateProcThreadAttribute, WaitForSingleObject, + CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + STARTF_USESTDHANDLES, STARTUPINFOEXW, + }; + + use super::Started; + use crate::protocol::{Args, SpawnRequest}; + + /// A pseudoconsole handle; ConPTY calls on it are safe from any thread. + struct Hpc(HPCON); + // SAFETY: an HPCON is an opaque handle the console host owns, not thread-bound memory. + unsafe impl Send for Hpc {} + // SAFETY: as above; ResizePseudoConsole may be called from any thread. + unsafe impl Sync for Hpc {} + + impl Drop for Hpc { + fn drop(&mut self) { + let hpc = self.0; + // Closing waits for the console host to finish; never on a runtime thread. + let raw = hpc as usize; + std::thread::spawn(move || { + // SAFETY: the handle came from CreatePseudoConsole and is closed once, here. + unsafe { ClosePseudoConsole(raw as HPCON) }; + }); + } + } + + pub struct Console { + hpc: Option, + /// Keeps the process handle open while the pane lives (its pid can't be reused). + _process: Arc, + } + + impl Console { + pub fn resize(&self, cols: u16, rows: u16) { + if let Some(h) = &self.hpc { + // SAFETY: a live pseudoconsole handle. + unsafe { ResizePseudoConsole(h.0, coord(cols, rows)) }; + } + } + + /// Closing the console ends what runs in it, as node-pty's kill() does on Windows. + pub fn soft_kill(mut self, _pid: i32) -> Self { + self.hpc = None; + self + } + } + + /// The whole tree, forced, if it outlived the console (node-pty's windowsKillPolicy). + pub fn hard_kill(pid: i32, leader_gone: bool) { + if leader_gone { + return; + } + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .status(); + } + + fn coord(cols: u16, rows: u16) -> COORD { + COORD { + X: cols.min(i16::MAX as u16) as i16, + Y: rows.min(i16::MAX as u16) as i16, + } + } + + fn wide(s: &str) -> Vec { + OsStr::new(s).encode_wide().chain(Some(0)).collect() + } + + /// One argument as CreateProcess's parser (CommandLineToArgvW) reads it back. + fn quote(arg: &str, out: &mut String) { + if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\x0b', '"']) { + out.push_str(arg); + return; + } + out.push('"'); + let mut backslashes = 0; + for c in arg.chars() { + match c { + '\\' => backslashes += 1, + '"' => { + out.extend(std::iter::repeat_n('\\', backslashes * 2 + 1)); + out.push('"'); + backslashes = 0; + } + _ => { + out.extend(std::iter::repeat_n('\\', backslashes)); + out.push(c); + backslashes = 0; + } + } + } + out.extend(std::iter::repeat_n('\\', backslashes * 2)); + out.push('"'); + } + + /// The command line: the file quoted, then each argument quoted, or a ready line as is + /// (node-pty joins a string `args` to the file the same way). + pub(super) fn command_line(file: &str, args: &Args) -> String { + let mut line = String::new(); + quote(file, &mut line); + match args { + Args::List(list) => { + for a in list { + line.push(' '); + quote(a, &mut line); + } + } + Args::Line(raw) => { + if !raw.is_empty() { + line.push(' '); + line.push_str(raw); + } + } + } + line + } + + /// `k=v\0…\0\0`, sorted by name without case as CreateProcess expects. + fn env_block(env: &[(String, String)]) -> Vec { + let mut vars: Vec<&(String, String)> = env.iter().collect(); + vars.sort_by_key(|(k, _)| k.to_uppercase()); + let mut block: Vec = Vec::new(); + for (k, v) in vars { + block.extend(OsStr::new(&format!("{k}={v}")).encode_wide()); + block.push(0); + } + if block.is_empty() { + block.push(0); + } + block.push(0); + block + } + + fn last_error(what: &str) -> String { + format!("{what}: {}", std::io::Error::last_os_error()) + } + + pub(super) fn start(req: &SpawnRequest) -> Result { + // SAFETY: plain Win32 calls; every handle made here is owned by exactly one wrapper + // (OwnedHandle, File, Hpc) or closed before returning. + unsafe { + let (mut in_read, mut in_write): (HANDLE, HANDLE) = (null_mut(), null_mut()); + let (mut out_read, mut out_write): (HANDLE, HANDLE) = (null_mut(), null_mut()); + if CreatePipe(&mut in_read, &mut in_write, null(), 0) == 0 { + return Err(last_error("CreatePipe")); + } + if CreatePipe(&mut out_read, &mut out_write, null(), 0) == 0 { + CloseHandle(in_read); + CloseHandle(in_write); + return Err(last_error("CreatePipe")); + } + let mut hpc: HPCON = 0 as HPCON; + let hr = + CreatePseudoConsole(coord(req.cols, req.rows), in_read, out_write, 0, &mut hpc); + // The console host has its own copies now. + CloseHandle(in_read); + CloseHandle(out_write); + let writer = File::from_raw_handle(in_write as _); + let reader = File::from_raw_handle(out_read as _); + if hr < 0 { + return Err(format!("CreatePseudoConsole failed (0x{hr:08x})")); + } + let hpc = Hpc(hpc); + + let mut size = 0usize; + InitializeProcThreadAttributeList(null_mut(), 1, 0, &mut size); + let mut list_buf = vec![0u8; size]; + let list = list_buf.as_mut_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST; + if InitializeProcThreadAttributeList(list, 1, 0, &mut size) == 0 { + return Err(last_error("InitializeProcThreadAttributeList")); + } + let ok = UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + hpc.0 as *const core::ffi::c_void, + std::mem::size_of::(), + null_mut(), + null(), + ); + if ok == 0 { + DeleteProcThreadAttributeList(list); + return Err(last_error("UpdateProcThreadAttribute")); + } + + let mut si: STARTUPINFOEXW = std::mem::zeroed(); + si.StartupInfo.cb = std::mem::size_of::() as u32; + // The host's own stdio goes to its log: without this the pane would inherit it + // instead of the pseudoconsole. + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdError = INVALID_HANDLE_VALUE; + si.lpAttributeList = list; + + let mut cmdline = wide(&command_line(&req.file, &req.args)); + let env = env_block(&req.env); + let cwd = wide(&req.cwd); + let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + let ok = CreateProcessW( + null(), + cmdline.as_mut_ptr(), + null(), + null(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + env.as_ptr() as *const core::ffi::c_void, + cwd.as_ptr(), + &si.StartupInfo, + &mut pi, + ); + DeleteProcThreadAttributeList(list); + if ok == 0 { + return Err(last_error(&format!("could not start {}", req.file))); + } + CloseHandle(pi.hThread); + let process = Arc::new(OwnedHandle::from_raw_handle(pi.hProcess as _)); + let waited = Arc::clone(&process); + Ok(Started { + pid: pi.dwProcessId as i32, + console: Console { + hpc: Some(hpc), + _process: process, + }, + reader: Box::new(reader), + writer: Box::new(writer), + wait: Box::new(move || { + let h = waited.as_raw_handle() as HANDLE; + let mut code = 0u32; + WaitForSingleObject(h, INFINITE); + GetExitCodeProcess(h, &mut code); + // Windows has no signals: node-pty reports none. + (i64::from(code), None) + }), + }) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn command_lines_quote_lists_and_keep_ready_lines() { + let list = Args::List(vec![ + "a b".into(), + "x\"y".into(), + "c:\\dir\\".into(), + "".into(), + ]); + assert_eq!( + command_line("C:\\Program Files\\x.exe", &list), + r#""C:\Program Files\x.exe" "a b" "x\"y" c:\dir\ """# + ); + let line = Args::Line(r#"/d /s /c ""C:\npm\claude.cmd" --x "a b"""#.into()); + assert_eq!( + command_line("C:\\Windows\\system32\\cmd.exe", &line), + r#"C:\Windows\system32\cmd.exe /d /s /c ""C:\npm\claude.cmd" --x "a b"""# + ); + } + } +} diff --git a/host-rs/src/server.rs b/host-rs/src/server.rs index 506e010..fafa685 100644 --- a/host-rs/src/server.rs +++ b/host-rs/src/server.rs @@ -11,7 +11,6 @@ use std::time::{Duration, Instant}; use regex::Regex; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::UnixStream; use tokio::sync::{mpsc, oneshot}; use crate::protocol::{self, ClientFrame, PaneState, Role}; @@ -160,8 +159,12 @@ impl Host { } /// Serves one connection until it closes. - pub async fn accept(self: Arc, stream: UnixStream) { - let (mut read, mut write) = stream.into_split(); + /// Serves one connection: a Unix socket, or a named pipe instance on Windows. + pub async fn accept(self: Arc, stream: S) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, + { + let (mut read, mut write) = tokio::io::split(stream); let (out, mut out_rx) = mpsc::unbounded_channel::(); let backlog = Arc::new(AtomicUsize::new(0)); let id = { diff --git a/host-rs/tests/smoke.rs b/host-rs/tests/smoke.rs new file mode 100644 index 0000000..0ac57b4 --- /dev/null +++ b/host-rs/tests/smoke.rs @@ -0,0 +1,204 @@ +//! The built host end to end, on every platform CI runs (ADR 0004 phase 3b): started as the GUI +//! starts it, reached over its socket (a named pipe on Windows), a pane that prints, one that +//! is typed into, then a shutdown. On Windows this is what proves the ConPTY spawn, the raw +//! `cmd.exe` command line and the pipe; the parity suite (tests/host) covers the rest on Unix. + +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +struct Host { + child: Child, + dir: PathBuf, + socket: String, +} + +impl Drop for Host { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn start_host() -> Host { + let stamp = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let dir = std::env::temp_dir().join(format!("wg-smoke-{stamp}")); + let socket = if cfg!(windows) { + format!(r"\\.\pipe\wraithgrid-smoke-{stamp}") + } else { + dir.join("host.sock").to_string_lossy().into_owned() + }; + let paths = json!({ + "dir": dir, + "socket": socket, + "token": dir.join("host.token"), + "lock": dir.join("host.lock"), + "log": dir.join("host.log"), + }); + let child = Command::new(env!("CARGO_BIN_EXE_wraithgrid-host")) + .env( + "WRAITHGRID_HOST", + json!({ "paths": paths, "appVersion": "smoke" }).to_string(), + ) + .stdin(Stdio::null()) + .spawn() + .expect("start the host"); + Host { child, dir, socket } +} + +fn read_token(h: &Host) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Ok(t) = std::fs::read_to_string(h.dir.join("host.token")) { + if t.len() == 64 { + return t; + } + } + assert!(Instant::now() < deadline, "the host never wrote its token"); + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[cfg(unix)] +async fn connect(socket: &str) -> impl AsyncRead + AsyncWrite { + tokio::net::UnixStream::connect(socket) + .await + .expect("connect") +} + +#[cfg(windows)] +async fn connect(socket: &str) -> impl AsyncRead + AsyncWrite { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match tokio::net::windows::named_pipe::ClientOptions::new().open(socket) { + Ok(c) => return c, + Err(e) if Instant::now() < deadline => { + let _ = e; + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(e) => panic!("connect: {e}"), + } + } +} + +/// A pane command line that prints `text` and exits 0. +fn printer(text: &str) -> (String, Value) { + if cfg!(windows) { + // A ready line, as platform.ts builds one for an npm `.cmd` CLI. + let comspec = std::env::var("ComSpec").unwrap_or_else(|_| "cmd.exe".into()); + (comspec, json!(format!("/d /s /c \"echo {text}\""))) + } else { + ("/bin/sh".into(), json!(["-c", format!("echo {text}")])) + } +} + +/// An interactive shell, and the line that makes it print `text`. +fn shell(text: &str) -> (String, Value, String) { + if cfg!(windows) { + let comspec = std::env::var("ComSpec").unwrap_or_else(|_| "cmd.exe".into()); + (comspec, json!(["/d", "/q"]), format!("echo {text}\r")) + } else { + ("/bin/sh".into(), json!([]), format!("echo {text}\n")) + } +} + +fn env() -> Value { + Value::Object( + std::env::vars() + .map(|(k, v)| (k, Value::String(v))) + .collect(), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pane_prints_takes_input_and_exits() { + let h = start_host(); + let token = read_token(&h); + let (read, mut write) = tokio::io::split(connect(&h.socket).await); + let mut lines = BufReader::new(read).lines(); + let mut send = async |frame: Value| { + write + .write_all(format!("{frame}\n").as_bytes()) + .await + .unwrap(); + }; + let cwd = std::env::temp_dir().to_string_lossy().into_owned(); + + send(json!({ "t": "hello", "protocol": 1, "appVersion": "smoke", "token": token })).await; + let (file, args) = printer("hello-from-pane"); + send(json!({ "t": "create", "id": 1, "req": { + "paneId": "p1", "file": file, "args": args, "cwd": cwd, "env": env(), "cols": 80, "rows": 24 + }})) + .await; + let (sh, sh_args, typed) = shell("typed-into-pane"); + send(json!({ "t": "create", "id": 2, "req": { + "paneId": "p2", "file": sh, "args": sh_args, "cwd": cwd, "env": env(), "cols": 80, "rows": 24 + }})) + .await; + + let mut welcome = false; + let mut out = std::collections::HashMap::::new(); + let mut p1_exit = None; + let mut typed_sent = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + while !(welcome + && p1_exit.is_some() + && out.get("p2").is_some_and(|o| o.contains("typed-into-pane"))) + { + let line = tokio::time::timeout_at(deadline, lines.next_line()) + .await + .unwrap_or_else(|_| panic!("timed out; seen so far: {out:?}")) + .unwrap() + .expect("the host closed the connection"); + let f: Value = serde_json::from_str(&line).unwrap(); + match f["t"].as_str().unwrap() { + "welcome" => welcome = true, + "refused" => panic!("refused: {f}"), + "reply" => assert_eq!(f["reply"]["ok"], true, "a create failed: {f}"), + "data" => { + let pane = f["paneId"].as_str().unwrap().to_string(); + out.entry(pane.clone()) + .or_default() + .push_str(f["data"].as_str().unwrap()); + if pane == "p2" && !typed_sent { + typed_sent = true; + send(json!({ "t": "write", "paneId": "p2", "data": typed })).await; + } + } + "exit" if f["paneId"] == "p1" => p1_exit = Some(f["exitCode"].as_i64().unwrap()), + _ => {} + } + } + assert!( + out["p1"].contains("hello-from-pane"), + "p1 printed: {:?}", + out["p1"] + ); + assert_eq!(p1_exit, Some(0)); + + send(json!({ "t": "shutdown", "id": 3, "exit": true })).await; + let mut h = h; + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if let Some(status) = h.child.try_wait().unwrap() { + assert!(status.success(), "the host exited with {status}"); + break; + } + assert!( + Instant::now() < deadline, + "the host did not exit on shutdown" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} diff --git a/package.json b/package.json index 19c9bba..f666c5b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "dist": "electron-vite build && electron-builder --publish never", "dist:linux": "electron-vite build && cargo build --release --manifest-path host-rs/Cargo.toml && bash scripts/build-remote-hosts.sh && electron-builder --linux --publish never", "dist:linux:portable": "bash scripts/dist-linux-docker.sh", - "dist:win": "electron-vite build && electron-builder --win --publish never", + "dist:win": "electron-vite build && cargo build --release --locked --manifest-path host-rs/Cargo.toml && electron-builder --win --publish never", "dist:mac": "electron-vite build && bash scripts/build-mac-host.sh && electron-builder --mac --publish never", "postinstall": "electron-builder install-app-deps" }, diff --git a/src/main/app.ts b/src/main/app.ts index 1c8019b..9d751da 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -181,11 +181,11 @@ const hostFiles = hostPaths({ * when packaged, the cargo release build next to `out/` in development; null when there is none. */ const rustHostBin = ((): string | null => { - // Linux and macOS (ADR 0004 phases 2, 3); Windows waits for its own ConPTY spawn. - if (process.platform !== 'linux' && process.platform !== 'darwin') return null + // Linux, macOS and Windows (ADR 0004 phases 2, 3a, 3b). + const name = process.platform === 'win32' ? 'wraithgrid-host.exe' : 'wraithgrid-host' const bin = app.isPackaged - ? path.join(process.resourcesPath, 'wraithgrid-host') - : path.join(__dirname, '../../host-rs/target/release/wraithgrid-host') + ? path.join(process.resourcesPath, name) + : path.join(__dirname, '../../host-rs/target/release', name) return existsSync(bin) ? bin : null })() diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index af4a202..a21abb2 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -631,13 +631,11 @@ function CliRow() { ) } -/** The Rust background host (ADR 0004): off by default, Linux and macOS builds that have it. */ +/** The Rust background host (ADR 0004): off by default, in builds that have it. */ function RustHostRow() { const actions = useActions() const on = useApp((s) => s.config.settings.rustHost) const available = useApp((s) => s.info.rustHost) - const unix = useApp((s) => s.info.platform === 'linux' || s.info.platform === 'darwin') - if (!unix) return null return (