From b0b6164f018c24f94aea9d73195239382a600039 Mon Sep 17 00:00:00 2001 From: keinstn Date: Thu, 25 Jun 2026 23:03:09 +0900 Subject: [PATCH 001/112] refactor(sys): add platform-abstraction layer for process and fs primitives Introduce src/sys/ as the single boundary for OS-specific behavior. Migrate process liveness/termination/session-setup and file identity/permissions off direct libc/nix/std::os::unix calls so call sites are platform-neutral. - sys::process: is_alive, kill, terminate, kill_child_group, detach_session (Unix: libc/nix signals + setsid; Windows: Win32 process APIs + job group flag) - sys::fs: set_private, set_executable, file_id (Unix: mode bits + inode; Windows: no-op modes + GetFileInformationByHandle) - Make nix/libc/signal-hook Unix-only; add windows-sys for the Windows backend. Migrated callers: pidtrack, shell_env, db, commands/{daemon,relay}. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 93 +++++++++++++++++++++--- Cargo.toml | 20 +++++- src/commands/daemon.rs | 7 +- src/commands/relay.rs | 4 +- src/db/mod.rs | 3 +- src/main.rs | 1 + src/pidtrack.rs | 12 +--- src/shell_env.rs | 41 ++--------- src/sys/fs.rs | 75 ++++++++++++++++++++ src/sys/mod.rs | 11 +++ src/sys/process.rs | 156 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 357 insertions(+), 66 deletions(-) create mode 100644 src/sys/fs.rs create mode 100644 src/sys/mod.rs create mode 100644 src/sys/process.rs diff --git a/Cargo.lock b/Cargo.lock index 75e1eb65..c928fe9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -896,6 +896,7 @@ dependencies = [ "uuid", "vt100", "webpki-roots", + "windows-sys 0.60.2", ] [[package]] @@ -2816,7 +2817,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2834,14 +2844,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2850,48 +2877,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 7e184c2c..7c123bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,10 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -nix = { version = "0.31.2", features = ["term", "signal", "poll", "process", "fs", "ioctl"] } vt100 = "0.16" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -libc = "0.2.184" rusqlite = { version = "0.39.0", features = ["bundled"] } regex = "1" thiserror = "2" @@ -38,7 +36,6 @@ toml_edit = "0.25.10" tempfile = "3" rand = "0.10.0" glob = "0.3" -signal-hook = "0.4.4" uuid = { version = "1.23.0", features = ["v4"] } # Relay (MQTT) dependencies rumqttc = { version = "0.25.1", default-features = false, features = ["use-rustls"] } @@ -51,6 +48,23 @@ webpki-roots = "1.0.6" rustls-native-certs = "0.8.3" lru = "0.16" +# Unix-only: PTY, signals, fd polling, file locks, process groups. +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31.2", features = ["term", "signal", "poll", "process", "fs", "ioctl"] } +libc = "0.2.184" +signal-hook = "0.4.4" + +# Windows-only: process/job-object control, winsock select, file locking. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.60", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_IO", + "Win32_Storage_FileSystem", + "Win32_Networking_WinSock", +] } + [dev-dependencies] serial_test = "3" diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 860094f5..2bd359f7 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -48,9 +48,7 @@ pub(crate) fn daemon_stop() -> i32 { } }; - unsafe { - libc::kill(pid as i32, libc::SIGTERM); - } + crate::sys::process::terminate(pid); println!("Sent SIGTERM to daemon (PID {pid})"); for _ in 0..50 { @@ -63,8 +61,7 @@ pub(crate) fn daemon_stop() -> i32 { } println!("Daemon did not respond to SIGTERM, escalating to SIGKILL"); - let kill_ret = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; - if kill_ret != 0 { + if !crate::sys::process::kill(pid) { eprintln!( "SIGKILL failed (errno {}), PID file retained", std::io::Error::last_os_error() diff --git a/src/commands/relay.rs b/src/commands/relay.rs index 1c4398ef..8201dd3a 100644 --- a/src/commands/relay.rs +++ b/src/commands/relay.rs @@ -366,9 +366,7 @@ fn stop_relay_worker_quiet() { // Last resort: a stale worker pinned to the old namespace must not survive // a relay reset or shutdown. - unsafe { - libc::kill(pid as i32, libc::SIGKILL); - } + crate::sys::process::kill(pid); crate::relay::worker::remove_relay_pid_file(); } diff --git a/src/db/mod.rs b/src/db/mod.rs index 1cd45840..707e4c35 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -65,8 +65,7 @@ pub struct HcomDb { } fn get_inode(path: &std::path::Path) -> u64 { - use std::os::unix::fs::MetadataExt; - std::fs::metadata(path).map(|m| m.ino()).unwrap_or(0) + crate::sys::fs::file_id(path) } impl HcomDb { diff --git a/src/main.rs b/src/main.rs index 7fc31cab..aff3aa08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod runtime_env; pub mod scripts; pub mod shared; mod shell_env; +mod sys; pub mod terminal; mod tool; pub mod tools; diff --git a/src/pidtrack.rs b/src/pidtrack.rs index 1e463655..8ab3f1e2 100644 --- a/src/pidtrack.rs +++ b/src/pidtrack.rs @@ -88,17 +88,9 @@ fn pidfile_path(hcom_dir: &Path) -> PathBuf { hcom_dir.join(PIDFILE_NAME) } -/// Check if a process is alive via `kill(pid, 0)`. -/// Handles EPERM (process exists but owned by another user). +/// Check if a process is alive. See [`crate::sys::process::is_alive`]. pub fn is_alive(pid: u32) -> bool { - // SAFETY: kill(pid, 0) is a no-op signal that just checks process existence. - let ret = unsafe { libc::kill(pid as i32, 0) }; - if ret == 0 { - return true; - } - // EPERM means process exists but is owned by another user - let err = std::io::Error::last_os_error(); - err.raw_os_error() == Some(libc::EPERM) + crate::sys::process::is_alive(pid) } /// Read raw pidfile data. diff --git a/src/shell_env.rs b/src/shell_env.rs index bf137fdf..1c4f36dc 100644 --- a/src/shell_env.rs +++ b/src/shell_env.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use std::fs; use std::io::Read; -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -115,14 +114,14 @@ fn timed_shell_output_with_timeout( } Ok(None) => { if start.elapsed() >= timeout { - kill_shell_process_group(&mut child); + crate::sys::process::kill_child_group(&mut child); let _ = child.wait(); return None; } std::thread::sleep(Duration::from_millis(25)); } Err(_) => { - kill_shell_process_group(&mut child); + crate::sys::process::kill_child_group(&mut child); let _ = child.wait(); return None; } @@ -142,39 +141,11 @@ fn shell_command(shell: &Path, cmd: &str, marker: &str) -> Command { .stdout(Stdio::piped()) .stderr(Stdio::null()); - #[cfg(unix)] - unsafe { - use std::os::unix::process::CommandExt; - command.pre_exec(|| { - if libc::setsid() == -1 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } - }); - } + crate::sys::process::detach_session(&mut command); command } -fn kill_shell_process_group(child: &mut std::process::Child) { - #[cfg(unix)] - { - use nix::sys::signal::{Signal, killpg}; - use nix::unistd::Pid; - - let Ok(raw_pid) = i32::try_from(child.id()) else { - let _ = child.kill(); - return; - }; - if killpg(Pid::from_raw(raw_pid), Signal::SIGKILL).is_ok() { - return; - } - } - - let _ = child.kill(); -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct ShellEnvCache { #[serde(default)] @@ -196,9 +167,9 @@ fn write_cache(path: &Path, entry: &ShellEnvCache) -> std::io::Result<()> { let content = serde_json::to_vec(entry).map_err(std::io::Error::other)?; let tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap_or_else(|| Path::new(".")))?; fs::write(tmp.path(), content)?; - fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600))?; + crate::sys::fs::set_private(tmp.path())?; tmp.persist(path).map_err(std::io::Error::other)?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + crate::sys::fs::set_private(path)?; Ok(()) } @@ -360,8 +331,10 @@ mod tests { assert!(!cache_is_fresh(&entry, 10, 101 + CACHE_TTL.as_secs())); } + #[cfg(unix)] #[test] fn cache_write_uses_private_permissions() { + use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("shell_env.json"); let entry = ShellEnvCache { diff --git a/src/sys/fs.rs b/src/sys/fs.rs new file mode 100644 index 00000000..7692a3b7 --- /dev/null +++ b/src/sys/fs.rs @@ -0,0 +1,75 @@ +//! Filesystem primitives that differ across platforms: Unix permission bits and +//! stable on-disk file identity. + +use std::io; +use std::path::Path; + +/// Restrict a file to owner-only read/write (`0o600` on Unix). +/// +/// No-op on Windows, where Unix mode bits do not apply and files created under +/// the user's profile are already private by default. +pub fn set_private(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Mark a file as executable (`0o755` on Unix). +/// +/// No-op on Windows, where executability is determined by file extension rather +/// than a mode bit. +pub fn set_executable(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Stable identity of a file on disk, used to detect replacement (atomic +/// rename/swap) of a path that keeps the same name. +/// +/// Unix: the inode number. Windows: the `nFileIndex` from +/// `GetFileInformationByHandle`. Returns 0 when the file cannot be inspected. +pub fn file_id(path: &Path) -> u64 { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(path).map(|m| m.ino()).unwrap_or(0) + } + #[cfg(windows)] + { + file_id_win(path).unwrap_or(0) + } +} + +#[cfg(windows)] +fn file_id_win(path: &Path) -> Option { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let file = std::fs::File::open(path).ok()?; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: `file` owns a valid handle for the duration of the call and + // `info` is a properly sized output buffer. + let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle() as HANDLE, &mut info) }; + if ok == 0 { + return None; + } + Some(((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64) +} diff --git a/src/sys/mod.rs b/src/sys/mod.rs new file mode 100644 index 00000000..47950098 --- /dev/null +++ b/src/sys/mod.rs @@ -0,0 +1,11 @@ +//! Platform-abstraction layer. +//! +//! Every OS-specific primitive the rest of hcom needs lives behind this module +//! so call sites never touch `nix`, `libc`, `std::os::unix`, or Windows APIs +//! directly. Each capability is a thin, platform-neutral function API; the +//! per-OS implementation is selected inline with `#[cfg]`. This turns the +//! historically scattered `#[cfg]` blocks into a single, auditable boundary and +//! keeps Unix and Windows behavior side by side. + +pub mod fs; +pub mod process; diff --git a/src/sys/process.rs b/src/sys/process.rs new file mode 100644 index 00000000..c2dd4c14 --- /dev/null +++ b/src/sys/process.rs @@ -0,0 +1,156 @@ +//! Process-control primitives: liveness, termination, and session/group setup. +//! +//! Unix uses `libc`/`nix` signals and `setsid`; Windows uses the Win32 process +//! and job-object APIs. See the module-level docs in [`crate::sys`]. + +use std::process::Command; + +/// Whether a process with the given PID is currently alive. +/// +/// Unix: `kill(pid, 0)`, treating `EPERM` (the process exists but is owned by +/// another user) as alive. Windows: `OpenProcess` — a successful handle, or a +/// failure with `ERROR_ACCESS_DENIED`, means the process exists. +pub fn is_alive(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: kill(pid, 0) sends no signal; it only checks for existence. + let ret = unsafe { libc::kill(pid as i32, 0) }; + if ret == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + } + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED, GetLastError}; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: query-only access mask; the handle is closed before returning. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if !handle.is_null() { + CloseHandle(handle); + return true; + } + GetLastError() == ERROR_ACCESS_DENIED + } + } +} + +/// Forcefully terminate a process by PID. Best-effort; returns whether the +/// request was delivered. +/// +/// Unix: `SIGKILL`. Windows: `TerminateProcess`. +pub fn kill(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: standard kill(2) with a valid signal number. + unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 } + } + #[cfg(windows)] + { + terminate_win(pid) + } +} + +/// Request termination of a process by PID. Best-effort; returns whether the +/// request was delivered. +/// +/// Unix: `SIGTERM` (graceful). Windows has no general-purpose `SIGTERM` for an +/// arbitrary unrelated process, so this maps to `TerminateProcess`. +pub fn terminate(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: standard kill(2) with a valid signal number. + unsafe { libc::kill(pid as i32, libc::SIGTERM) == 0 } + } + #[cfg(windows)] + { + terminate_win(pid) + } +} + +/// Kill a child process together with its process group. +/// +/// Unix: `killpg(SIGKILL)` on the child's group (set up via [`detach_session`]), +/// falling back to `Child::kill` if the group signal fails. Windows: terminates +/// the child process directly (full job-object group semantics are a later +/// phase). +pub fn kill_child_group(child: &mut std::process::Child) { + #[cfg(unix)] + { + use nix::sys::signal::{Signal, killpg}; + use nix::unistd::Pid; + + if let Ok(raw_pid) = i32::try_from(child.id()) + && killpg(Pid::from_raw(raw_pid), Signal::SIGKILL).is_ok() + { + return; + } + } + + let _ = child.kill(); +} + +/// Put a not-yet-spawned [`Command`] into its own session / process group, so +/// the resulting child can be signalled as a group and is detached from the +/// parent's controlling terminal. +/// +/// Unix: `setsid()` via a `pre_exec` hook. Windows: `CREATE_NEW_PROCESS_GROUP`. +pub fn detach_session(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // SAFETY: setsid() runs in the child between fork and exec and is + // async-signal-safe. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command.creation_flags(CREATE_NEW_PROCESS_GROUP); + } +} + +#[cfg(windows)] +fn terminate_win(pid: u32) -> bool { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_TERMINATE, TerminateProcess, + }; + // SAFETY: opens a terminate-only handle, closes it before returning. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); + if handle.is_null() { + return false; + } + let ok = TerminateProcess(handle, 1) != 0; + CloseHandle(handle); + ok + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_alive_current_process() { + assert!(is_alive(std::process::id())); + } + + #[test] + fn test_is_alive_dead_process() { + assert!(!is_alive(99_999_999)); + } +} From 541b7d7f6eacaa2d9bdadd58d3c4cc555dadf249 Mon Sep 17 00:00:00 2001 From: keinstn Date: Thu, 25 Jun 2026 23:24:00 +0900 Subject: [PATCH 002/112] refactor(pty): gate the Unix PTY wrapper behind cfg(unix) The PTY proxy is built on nix openpty/termios/poll and cannot compile for Windows. Gate the module and its entry points so the rest of hcom builds on Windows, where run_pty returns a clear 'not yet supported' error. - main.rs: #[cfg(unix)] mod pty; split run_pty into Unix impl + Windows stub. - Move EXIT_WAS_KILLED from pty into delivery so the delivery loop compiles without the wrapper; the proxy still sets it on Unix. Co-Authored-By: Claude Opus 4.8 --- src/delivery.rs | 8 +++++++- src/main.rs | 14 ++++++++++++++ src/pty/mod.rs | 7 +++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index e25a163e..be67293b 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -15,6 +15,12 @@ use crate::log::{log_error, log_info, log_warn}; use crate::notify::NotifyServer; use crate::shared::{ST_ACTIVE, ST_BLOCKED, ST_INACTIVE, ST_LISTENING}; +/// Whether the wrapped child exited because hcom killed it (vs. closed on its +/// own). Set by the PTY proxy (Unix) and read here during delivery cleanup to +/// choose the exit status context. Lives here rather than in `pty` so the +/// delivery loop compiles on platforms without the PTY wrapper. +pub static EXIT_WAS_KILLED: AtomicBool = AtomicBool::new(false); + /// Safely truncate a string to at most `max_chars` characters. /// Unlike byte slicing `&s[..n]`, this won't panic on multi-byte UTF-8. pub(crate) fn truncate_chars(s: &str, max_chars: usize) -> String { @@ -1970,7 +1976,7 @@ pub(crate) fn cleanup_deleted_instance(db: &mut HcomDb, current_name: &str) { } }; - let was_killed = crate::pty::EXIT_WAS_KILLED.load(std::sync::atomic::Ordering::Acquire); + let was_killed = EXIT_WAS_KILLED.load(std::sync::atomic::Ordering::Acquire); let (exit_context, exit_reason) = if was_killed { ("exit:killed", "killed") } else { diff --git a/src/main.rs b/src/main.rs index aff3aa08..7d2dc07e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ pub mod messages; mod notify; mod paths; mod pidtrack; +#[cfg(unix)] mod pty; pub mod relay; pub mod router; @@ -68,6 +69,17 @@ fn main() -> Result<()> { } /// Run PTY wrapper mode. +/// +/// The PTY wrapper (screen tracking + message injection) is built on Unix +/// pseudo-terminals and is not yet available on Windows; native ConPTY support +/// is a later phase. Messaging, relay, hooks, and launching all work without it. +#[cfg(windows)] +pub fn run_pty(_args: &[String]) -> Result<()> { + bail!("PTY wrapper mode is not yet supported on Windows"); +} + +/// Run PTY wrapper mode. +#[cfg(unix)] pub fn run_pty(args: &[String]) -> Result<()> { if args.is_empty() || args[0] == "--help" || args[0] == "-h" { eprintln!("hcom pty - PTY wrapper for hcom"); @@ -159,6 +171,7 @@ pub fn run_pty(args: &[String]) -> Result<()> { std::process::exit(exit_code); } +#[cfg(unix)] fn pty_child_env() -> Vec<(String, String)> { vec![("HCOM_LAUNCHED".to_string(), "1".to_string())] } @@ -212,6 +225,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn test_pty_child_env_marks_launched() { assert_eq!( diff --git a/src/pty/mod.rs b/src/pty/mod.rs index 03c4f32b..21bd06df 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -517,10 +517,9 @@ static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false); static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false); static SIGHUP_RECEIVED: AtomicBool = AtomicBool::new(false); -// Exit reason flag (for cleanup to know context) -// false = normal exit (closed), true = signal exit (killed) -// Pub so delivery.rs can check it during cleanup -pub static EXIT_WAS_KILLED: AtomicBool = AtomicBool::new(false); +// Exit reason flag lives in `delivery` so the delivery loop compiles without +// the PTY wrapper; the proxy sets it here. +use crate::delivery::EXIT_WAS_KILLED; pub extern "C" fn handle_sigwinch(_: libc::c_int) { SIGWINCH_RECEIVED.store(true, Ordering::Release); From 4d671210ae953b924d844509cdc172929bc045b3 Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 00:26:29 +0900 Subject: [PATCH 003/112] refactor(sys): route remaining OS primitives through the abstraction layer Add sys::{net,io,signal} and extend sys::{fs,process} so all direct libc/nix/std::os::unix usage outside the Unix-only PTY module funnels through the platform boundary. With this, the crate compiles for x86_64-pc-windows (cargo check) while macOS/Linux behavior is unchanged. - sys::net::wait_readable: notify-server socket wait (Unix poll / Windows select) - sys::io::stdin_appears_broken: orphan-detection stdin probe - sys::signal::register_{term,int}: shutdown flags (signal-hook / ConsoleCtrlHandler) - sys::fs: lock_exclusive, create_private_new, is_socket - sys::process: terminate_group/kill_group (GroupSignal), exec_replace Migrated: notify/server, hooks/common, commands/listen, relay/{worker,mod}, instance_names, instance_lifecycle, launcher, router, terminal. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 1 + src/commands/listen.rs | 10 +--- src/hooks/common.rs | 50 ++++-------------- src/instance_lifecycle.rs | 2 +- src/instance_names.rs | 11 ++-- src/launcher.rs | 9 +--- src/notify/server.rs | 22 +++----- src/relay/mod.rs | 4 +- src/relay/worker.rs | 57 +++++---------------- src/router.rs | 7 +-- src/sys/fs.rs | 79 +++++++++++++++++++++++++++++ src/sys/io.rs | 29 +++++++++++ src/sys/mod.rs | 3 ++ src/sys/net.rs | 49 ++++++++++++++++++ src/sys/process.rs | 84 +++++++++++++++++++++++++++++++ src/sys/signal.rs | 103 ++++++++++++++++++++++++++++++++++++++ src/terminal.rs | 61 ++++++---------------- 17 files changed, 406 insertions(+), 175 deletions(-) create mode 100644 src/sys/io.rs create mode 100644 src/sys/net.rs create mode 100644 src/sys/signal.rs diff --git a/Cargo.toml b/Cargo.toml index 7c123bb4..61b67687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ windows-sys = { version = "0.60", features = [ "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_IO", + "Win32_System_Console", "Win32_Storage_FileSystem", "Win32_Networking_WinSock", ] } diff --git a/src/commands/listen.rs b/src/commands/listen.rs index d40744ec..ca6e9916 100644 --- a/src/commands/listen.rs +++ b/src/commands/listen.rs @@ -208,10 +208,7 @@ pub fn cmd_listen(db: &HcomDb, args: &ListenArgs, ctx: Option<&CommandContext>) if let Some(ref filter) = combined_sql { // Setup SIGTERM handler for filter mode let shutdown = Arc::new(AtomicBool::new(false)); - { - let shutdown_flag = Arc::clone(&shutdown); - let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, shutdown_flag); - } + crate::sys::signal::register_term(&shutdown); return listen_with_filter( db, filter, @@ -251,10 +248,7 @@ pub fn cmd_listen(db: &HcomDb, args: &ListenArgs, ctx: Option<&CommandContext>) // Setup SIGTERM handler for clean shutdown let shutdown = Arc::new(AtomicBool::new(false)); - { - let shutdown_flag = Arc::clone(&shutdown); - let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, shutdown_flag); - } + crate::sys::signal::register_term(&shutdown); // Check if already disconnected if db diff --git a/src/hooks/common.rs b/src/hooks/common.rs index 8f632cf1..a0151f23 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -499,18 +499,8 @@ fn poll_loop( }; if let Some(server) = notify_server { - // Block on poll(2) instead of busy-looping with accept+sleep - use std::os::fd::AsRawFd; - let fd = server.as_raw_fd(); - let timeout_ms = wait_time.as_millis().min(i32::MAX as u128) as i32; - let mut pfd = libc::pollfd { - fd, - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: valid pollfd, nfds=1, bounded timeout - let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, timeout_ms) }; - if ret > 0 { + // Block until a wake-up connection arrives instead of busy-looping + if crate::sys::net::wait_readable(server, wait_time) { // Drain all pending connections if let Err(e) = server.set_nonblocking(true) { log::log_warn( @@ -545,18 +535,7 @@ fn poll_loop( /// POLLERR (broken pipe) and POLLNVAL (fd was closed/invalidated). /// fn check_stdin_closed() -> bool { - let mut pfd = libc::pollfd { - fd: 0, // stdin - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: valid pollfd, nfds=1, timeout=0 - let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, 0) }; - if ret < 0 { - return true; // poll error → assume closed - } - // Only POLLERR/POLLNVAL — NOT POLLHUP (normal pipe EOF) - (pfd.revents & (libc::POLLERR | libc::POLLNVAL)) != 0 + crate::sys::io::stdin_appears_broken() } /// Create TCP server socket for instant message wake notifications. @@ -980,36 +959,27 @@ fn stop_instance_inner( let pid = instance_data.pid; let is_headless = instance_data.background != 0; if let Some(pid_val) = pid { - let pid_i32 = pid_val as i32; + let pid_u32 = pid_val as u32; if is_headless { // SIGTERM → wait up to 2s → SIGKILL - let term_ret = unsafe { libc::killpg(pid_i32, libc::SIGTERM) }; - if term_ret == 0 { + use crate::sys::process::GroupSignal; + if crate::sys::process::terminate_group(pid_u32) == GroupSignal::Sent { let mut dead = false; for _ in 0..20 { std::thread::sleep(Duration::from_millis(100)); - let probe = unsafe { libc::kill(pid_i32, 0) }; - if probe != 0 { + if !crate::sys::process::is_alive(pid_u32) { dead = true; break; } } if !dead { - unsafe { libc::killpg(pid_i32, libc::SIGKILL) }; + crate::sys::process::kill_group(pid_u32); } } - // ESRCH/EPERM from initial killpg is fine — process already gone or foreign + // NotFound/PermissionDenied from initial signal is fine — process already gone or foreign } else { // Track surviving PTY processes in pidtrack - let alive = { - let probe = unsafe { libc::kill(pid_i32, 0) }; - if probe == 0 { - true - } else { - // EPERM = exists but foreign user — still track - std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) - } - }; + let alive = crate::sys::process::is_alive(pid_u32); if alive { let hcom_dir = crate::paths::hcom_dir(); diff --git a/src/instance_lifecycle.rs b/src/instance_lifecycle.rs index 2a4f571d..f5a88e09 100644 --- a/src/instance_lifecycle.rs +++ b/src/instance_lifecycle.rs @@ -337,7 +337,7 @@ pub(crate) fn finalize_launch_failure_detail( 0 }; let process_state = data.pid.and_then(|pid| { - let alive = unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }; + let alive = crate::sys::process::is_alive(pid as u32); alive.then(|| format!("process alive {age}s, never bound")) }); let mut detail = fallback_detail diff --git a/src/instance_names.rs b/src/instance_names.rs index 4b91f1a1..49c98698 100644 --- a/src/instance_names.rs +++ b/src/instance_names.rs @@ -332,10 +332,9 @@ pub(crate) fn reserve_generated_name(db: &HcomDb) -> Result { .truncate(false) .open(&lock_path)?; - // Acquire exclusive file lock - use nix::fcntl::{Flock, FlockArg}; - let flock = Flock::lock(lock_file, FlockArg::LockExclusive) - .map_err(|(_, e)| anyhow::anyhow!("flock failed: {}", e))?; + // Acquire exclusive file lock; released when `lock_file` drops at scope end. + crate::sys::fs::lock_exclusive(&lock_file) + .map_err(|e| anyhow::anyhow!("flock failed: {}", e))?; let result = (|| -> Result { let name = allocate_unreserved_name(db)?; @@ -357,8 +356,8 @@ pub(crate) fn reserve_generated_name(db: &HcomDb) -> Result { Ok(name) })(); - // Unlock (drop the flock guard) - let _file = Flock::unlock(flock); + // Lock released when `lock_file` is dropped at function scope end. + drop(lock_file); result } diff --git a/src/launcher.rs b/src/launcher.rs index be32e6cb..7dd54327 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use std::fs; use std::io::Write; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::Path; use anyhow::{Result, bail}; @@ -763,11 +762,7 @@ pub fn create_runner_script( std::process::id(), rand::random::() % 9000 + 1000 )); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&env_file)?; + let mut file = crate::sys::fs::create_private_new(&env_file)?; writeln!( file, "{}", @@ -852,7 +847,7 @@ pub fn create_runner_script( ); fs::write(&script_file, &content)?; - fs::set_permissions(&script_file, fs::Permissions::from_mode(0o755))?; + crate::sys::fs::set_executable(&script_file)?; crate::log::log_info( "pty", diff --git a/src/notify/server.rs b/src/notify/server.rs index a5c2fe45..53b93b9c 100644 --- a/src/notify/server.rs +++ b/src/notify/server.rs @@ -9,11 +9,8 @@ use anyhow::{Context, Result}; use std::net::TcpListener; -use std::os::fd::{AsRawFd, BorrowedFd}; use std::time::Duration; -use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; - /// TCP notification server for wake-ups pub struct NotifyServer { listener: TcpListener, @@ -41,19 +38,12 @@ impl NotifyServer { /// /// Returns true if notified (connection received), false on timeout pub fn wait(&self, timeout: Duration) -> bool { - let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; - let poll_timeout = PollTimeout::try_from(timeout_ms).unwrap_or(PollTimeout::MAX); - - let fd = unsafe { BorrowedFd::borrow_raw(self.listener.as_raw_fd()) }; - let mut poll_fds = [PollFd::new(fd, PollFlags::POLLIN)]; - - match poll(&mut poll_fds, poll_timeout) { - Ok(n) if n > 0 => { - // Drain all pending notifications - self.drain(); - true - } - _ => false, + if crate::sys::net::wait_readable(&self.listener, timeout) { + // Drain all pending notifications + self.drain(); + true + } else { + false } } diff --git a/src/relay/mod.rs b/src/relay/mod.rs index 61d1b01c..752aa5ec 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -179,8 +179,8 @@ fn read_or_create_device_uuid_at(path: &std::path::Path) -> Option { .open(&lock_path) .ok()?; - use nix::fcntl::{Flock, FlockArg}; - let _flock = Flock::lock(lock_file, FlockArg::LockExclusive).ok()?; + // Held until `lock_file` drops at function scope end. + crate::sys::fs::lock_exclusive(&lock_file).ok()?; // Re-check under lock — a concurrent caller may have written by now. if let Some(uuid) = read_nonempty(path) { diff --git a/src/relay/worker.rs b/src/relay/worker.rs index 8188cf66..0d776a1c 100644 --- a/src/relay/worker.rs +++ b/src/relay/worker.rs @@ -7,7 +7,6 @@ //! before spawning a new relay-worker process. use std::net::TcpListener; -use std::os::fd::AsRawFd; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::Arc; @@ -153,25 +152,11 @@ pub fn run() -> i32 { // CLI callers (hcom send, hooks) connect to trigger immediate push. let notify_port = setup_notify_listener(&cmd_tx); - // Install signal handlers via signal-hook (sets AtomicBool on SIGTERM/SIGINT). + // Install shutdown-signal handlers (set AtomicBool on terminate/interrupt). // The watchdog thread checks this flag — no separate signal-polling thread needed. let shutdown = Arc::new(AtomicBool::new(false)); - if let Err(e) = signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&shutdown)) - { - log::log_error( - "relay", - "signal.register.sigterm", - &format!("Failed to register SIGTERM handler: {}", e), - ); - } - if let Err(e) = signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&shutdown)) - { - log::log_error( - "relay", - "signal.register.sigint", - &format!("Failed to register SIGINT handler: {}", e), - ); - } + crate::sys::signal::register_term(&shutdown); + crate::sys::signal::register_int(&shutdown); // Spawn auto-exit watchdog thread (also monitors shutdown flag) let cmd_tx_watchdog = cmd_tx; @@ -339,16 +324,9 @@ fn do_spawn() -> bool { } }; - loop { - let lock_ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) }; - if lock_ret == 0 { - break; - } - let err = std::io::Error::last_os_error(); - if err.kind() != std::io::ErrorKind::Interrupted { - log::log_warn("relay", "relay_worker.spawn_lock_err", &format!("{err}")); - return false; - } + if let Err(err) = crate::sys::fs::lock_exclusive(&lock_file) { + log::log_warn("relay", "relay_worker.spawn_lock_err", &format!("{err}")); + return false; } if is_relay_worker_running() { @@ -381,16 +359,7 @@ fn do_spawn() -> bool { .stderr(Stdio::null()); // Detach into own session so it survives parent terminal close (no SIGHUP) - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - } + crate::sys::process::detach_session(&mut cmd); match cmd.spawn() { Ok(child) => { @@ -529,13 +498,11 @@ pub fn remove_relay_pid_file() { /// Stop a running relay-worker by sending SIGTERM to the PID from PID file. pub fn stop_relay_worker() -> bool { - if let Some(pid) = read_pid_file() { - // SAFETY: Sending SIGTERM to a known PID. - let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; - if ret == 0 { - log::log_info("relay", "relay_worker.stopped", &format!("pid={}", pid)); - return true; - } + if let Some(pid) = read_pid_file() + && crate::sys::process::terminate(pid) + { + log::log_info("relay", "relay_worker.stopped", &format!("pid={}", pid)); + return true; } false } diff --git a/src/router.rs b/src/router.rs index a3b927b2..4eb0c104 100644 --- a/src/router.rs +++ b/src/router.rs @@ -3,7 +3,6 @@ //! All hooks and commands are handled natively in Rust. use std::env; -use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -397,8 +396,10 @@ pub fn maybe_reexec_dev_root() { // Re-exec: replace this process with the dev root's binary let args: Vec = env::args().collect(); - let err = Command::new(&target_binary).args(&args[1..]).exec(); - // exec() only returns on error + let mut cmd = Command::new(&target_binary); + cmd.args(&args[1..]); + let err = crate::sys::process::exec_replace(cmd); + // exec_replace only returns on error log_error( "router", "dev_root_reexec_failed", diff --git a/src/sys/fs.rs b/src/sys/fs.rs index 7692a3b7..2555491d 100644 --- a/src/sys/fs.rs +++ b/src/sys/fs.rs @@ -1,9 +1,88 @@ //! Filesystem primitives that differ across platforms: Unix permission bits and //! stable on-disk file identity. +use std::fs::File; use std::io; use std::path::Path; +/// Create a new file restricted to the owner (`0o600` on Unix), failing if it +/// already exists. On Windows the file is created with default ACLs (no Unix +/// mode); profile-local files are already private. +pub fn create_private_new(path: &Path) -> io::Result { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts.open(path) +} + +/// Acquire a blocking exclusive lock on an open file. The lock is released when +/// the file handle is closed (dropped). +/// +/// Unix: `flock(LOCK_EX)`, retrying on `EINTR`. Windows: `LockFileEx` with +/// `LOCKFILE_EXCLUSIVE_LOCK` over the whole file. +pub fn lock_exclusive(file: &File) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + loop { + // SAFETY: flock on a valid fd; return value is checked. + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if ret == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::Interrupted { + return Err(err); + } + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LockFileEx}; + use windows_sys::Win32::System::IO::OVERLAPPED; + + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + // SAFETY: valid handle for the file's lifetime; whole-file range. + let ok = unsafe { + LockFileEx( + file.as_raw_handle() as HANDLE, + LOCKFILE_EXCLUSIVE_LOCK, + 0, + u32::MAX, + u32::MAX, + &mut overlapped, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + +/// Whether `path` is a Unix-domain socket. Always false on Windows, which has +/// no filesystem socket node type. +pub fn is_socket(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + std::fs::metadata(path) + .map(|m| m.file_type().is_socket()) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + let _ = path; + false + } +} + /// Restrict a file to owner-only read/write (`0o600` on Unix). /// /// No-op on Windows, where Unix mode bits do not apply and files created under diff --git a/src/sys/io.rs b/src/sys/io.rs new file mode 100644 index 00000000..dbf49a0d --- /dev/null +++ b/src/sys/io.rs @@ -0,0 +1,29 @@ +//! Low-level stdio inspection used for orphan detection. + +/// Whether this process's stdin appears broken/invalid (a heuristic for +/// detecting that the launching parent is gone). +/// +/// Unix: a non-blocking `poll` of fd 0 that reports `POLLERR`/`POLLNVAL` (but +/// *not* `POLLHUP`, which is the normal end of a piped payload). Windows: always +/// false — pipe close semantics differ and orphan detection there relies on +/// other signals. +pub fn stdin_appears_broken() -> bool { + #[cfg(unix)] + { + let mut pfd = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: single valid pollfd, nfds=1, timeout=0 (non-blocking). + let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, 0) }; + if ret < 0 { + return true; + } + (pfd.revents & (libc::POLLERR | libc::POLLNVAL)) != 0 + } + #[cfg(not(unix))] + { + false + } +} diff --git a/src/sys/mod.rs b/src/sys/mod.rs index 47950098..f34a1438 100644 --- a/src/sys/mod.rs +++ b/src/sys/mod.rs @@ -8,4 +8,7 @@ //! keeps Unix and Windows behavior side by side. pub mod fs; +pub mod io; +pub mod net; pub mod process; +pub mod signal; diff --git a/src/sys/net.rs b/src/sys/net.rs new file mode 100644 index 00000000..84f439c5 --- /dev/null +++ b/src/sys/net.rs @@ -0,0 +1,49 @@ +//! Socket readiness waiting, used by the notify servers to block until a +//! wake-up connection arrives instead of busy-polling. + +use std::net::TcpListener; +use std::time::Duration; + +/// Block until `listener` has an incoming connection ready to accept, or +/// `timeout` elapses. Returns true if readable, false on timeout. +/// +/// Unix: `poll(POLLIN)`. Windows: `select` over the socket. +pub fn wait_readable(listener: &TcpListener, timeout: Duration) -> bool { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; + let mut pfd = libc::pollfd { + fd: listener.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: single valid pollfd, nfds=1, bounded timeout. + let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, timeout_ms) }; + ret > 0 + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{FD_SET, SOCKET, TIMEVAL, select}; + + let mut set: FD_SET = unsafe { std::mem::zeroed() }; + set.fd_count = 1; + set.fd_array[0] = listener.as_raw_socket() as SOCKET; + let tv = TIMEVAL { + tv_sec: timeout.as_secs().min(i32::MAX as u64) as i32, + tv_usec: timeout.subsec_micros() as i32, + }; + // SAFETY: read set holds one valid socket; nfds is ignored on Windows. + let ret = unsafe { + select( + 0, + &mut set, + std::ptr::null_mut(), + std::ptr::null_mut(), + &tv, + ) + }; + ret > 0 + } +} diff --git a/src/sys/process.rs b/src/sys/process.rs index c2dd4c14..637d123f 100644 --- a/src/sys/process.rs +++ b/src/sys/process.rs @@ -71,6 +71,90 @@ pub fn terminate(pid: u32) -> bool { } } +/// Outcome of signalling a process group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupSignal { + /// The signal was delivered. + Sent, + /// No such process/group exists (already gone). + NotFound, + /// The caller lacks permission to signal the group. + PermissionDenied, + /// Any other failure. + Other, +} + +/// Send a graceful termination request to a process group by PID. +/// +/// Unix: `killpg(SIGTERM)`. Windows has no process-group signal, so this +/// terminates the single process. +pub fn terminate_group(pid: u32) -> GroupSignal { + #[cfg(unix)] + { + signal_group_unix(pid, libc::SIGTERM) + } + #[cfg(windows)] + { + if terminate_win(pid) { + GroupSignal::Sent + } else { + GroupSignal::NotFound + } + } +} + +/// Forcefully kill a process group by PID. +/// +/// Unix: `killpg(SIGKILL)`. Windows terminates the single process. +pub fn kill_group(pid: u32) -> GroupSignal { + #[cfg(unix)] + { + signal_group_unix(pid, libc::SIGKILL) + } + #[cfg(windows)] + { + if terminate_win(pid) { + GroupSignal::Sent + } else { + GroupSignal::NotFound + } + } +} + +#[cfg(unix)] +fn signal_group_unix(pid: u32, sig: libc::c_int) -> GroupSignal { + // SAFETY: killpg with a valid signal number; return value is checked. + let ret = unsafe { libc::killpg(pid as i32, sig) }; + if ret == 0 { + return GroupSignal::Sent; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => GroupSignal::NotFound, + Some(libc::EPERM) => GroupSignal::PermissionDenied, + _ => GroupSignal::Other, + } +} + +/// Replace the current process with the given command. +/// +/// Unix uses `exec()` and only returns (an error) on failure. Windows has no +/// `exec`, so it spawns the command, waits, and exits with the child's status +/// code — likewise not returning on success. +pub fn exec_replace(mut cmd: Command) -> std::io::Error { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.exec() + } + #[cfg(windows)] + { + match cmd.status() { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(e) => e, + } + } +} + /// Kill a child process together with its process group. /// /// Unix: `killpg(SIGKILL)` on the child's group (set up via [`detach_session`]), diff --git a/src/sys/signal.rs b/src/sys/signal.rs new file mode 100644 index 00000000..ad6cfc4a --- /dev/null +++ b/src/sys/signal.rs @@ -0,0 +1,103 @@ +//! Shutdown-signal registration: set an `AtomicBool` when the process is asked +//! to terminate, so long-running loops can exit cleanly. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +/// Register `flag` to be set on a termination request (Unix `SIGTERM`; Windows +/// console close/break/shutdown events). +pub fn register_term(flag: &Arc) { + #[cfg(unix)] + { + let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(flag)); + } + #[cfg(windows)] + { + win::register(win::Kind::Term, Arc::clone(flag)); + } +} + +/// Register `flag` to be set on an interrupt request (Unix `SIGINT`; Windows +/// Ctrl-C). +pub fn register_int(flag: &Arc) { + #[cfg(unix)] + { + let _ = signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(flag)); + } + #[cfg(windows)] + { + win::register(win::Kind::Int, Arc::clone(flag)); + } +} + +#[cfg(windows)] +mod win { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex, OnceLock}; + use windows_sys::Win32::System::Console::{ + CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT, + SetConsoleCtrlHandler, + }; + + // Win32 BOOL is a plain i32; TRUE = 1, FALSE = 0. + const TRUE: i32 = 1; + const FALSE: i32 = 0; + + #[derive(Clone, Copy)] + pub enum Kind { + Int, + Term, + } + + static INT_FLAGS: OnceLock>>> = OnceLock::new(); + static TERM_FLAGS: OnceLock>>> = OnceLock::new(); + static INSTALLED: AtomicBool = AtomicBool::new(false); + + fn int_flags() -> &'static Mutex>> { + INT_FLAGS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn term_flags() -> &'static Mutex>> { + TERM_FLAGS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn set_all(flags: &Mutex>>) -> bool { + if let Ok(list) = flags.lock() { + if list.is_empty() { + return false; + } + for f in list.iter() { + f.store(true, Ordering::SeqCst); + } + return true; + } + false + } + + unsafe extern "system" fn handler(ctrl_type: u32) -> i32 { + let handled = match ctrl_type { + CTRL_C_EVENT => set_all(int_flags()), + CTRL_BREAK_EVENT | CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => { + set_all(term_flags()) + } + _ => false, + }; + if handled { TRUE } else { FALSE } + } + + pub fn register(kind: Kind, flag: Arc) { + if !INSTALLED.swap(true, Ordering::SeqCst) { + // SAFETY: installs a process-wide console control handler once. + unsafe { + SetConsoleCtrlHandler(Some(handler), TRUE); + } + } + let flags = match kind { + Kind::Int => int_flags(), + Kind::Term => term_flags(), + }; + if let Ok(mut list) = flags.lock() { + list.push(flag); + } + } +} diff --git a/src/terminal.rs b/src/terminal.rs index e1f1064b..4597c575 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -10,7 +10,6 @@ use std::collections::HashMap; use std::fs; use std::io::Write; -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -279,13 +278,8 @@ pub fn find_kitty_socket() -> String { candidates.sort_by(|a, b| b.cmp(a)); // Reverse sort (newest first) for sock_path in &candidates { - // Check if it's a socket - if let Ok(meta) = fs::metadata(sock_path) { - use std::os::unix::fs::FileTypeExt; - if !meta.file_type().is_socket() { - continue; - } - } else { + // Skip anything that isn't a Unix-domain socket + if !crate::sys::fs::is_socket(sock_path) { continue; } @@ -804,7 +798,7 @@ pub fn create_bash_script( } // Make executable - fs::set_permissions(script_file, fs::Permissions::from_mode(0o755))?; + crate::sys::fs::set_executable(script_file)?; Ok(()) } @@ -1439,16 +1433,7 @@ pub fn launch_terminal( .stderr(log_handle); // Detach child into its own session so it survives parent exit (no SIGHUP) - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - } + crate::sys::process::detach_session(&mut cmd); let child = cmd.spawn().context("Failed to launch background process")?; @@ -1478,26 +1463,12 @@ pub fn launch_terminal( if let Some(dir) = cwd { std::env::set_current_dir(dir).ok(); } - // Use execve to replace this process entirely - use std::ffi::CString; + // Replace this process entirely with the script's shell. let bash_path = which_bin("bash").unwrap_or_else(|| "/bin/bash".to_string()); - let bash = CString::new(bash_path).unwrap(); - let arg0 = CString::new("bash").unwrap(); - let arg1 = CString::new(script_file.to_string_lossy().as_ref()).unwrap(); - let argv_ptrs: Vec<*const libc::c_char> = - vec![arg0.as_ptr(), arg1.as_ptr(), std::ptr::null()]; - let env_cstrings: Vec = full_env - .iter() - .filter_map(|(k, v)| CString::new(format!("{}={}", k, v)).ok()) - .collect(); - let mut env_ptrs: Vec<*const libc::c_char> = - env_cstrings.iter().map(|c| c.as_ptr()).collect(); - env_ptrs.push(std::ptr::null()); - // execve replaces process; never returns on success - unsafe { - libc::execve(bash.as_ptr(), argv_ptrs.as_ptr(), env_ptrs.as_ptr()); - } - bail!("execve failed: {}", std::io::Error::last_os_error()); + let mut cmd = Command::new(&bash_path); + cmd.arg(script_file).env_clear().envs(&full_env); + let err = crate::sys::process::exec_replace(cmd); + bail!("exec failed: {}", err); } // New window / custom command mode @@ -1865,15 +1836,11 @@ pub fn kill_process( }; // SIGTERM the process group - let result = unsafe { libc::killpg(pid as i32, libc::SIGTERM) }; - let kill_result = if result == 0 { - KillResult::Sent - } else { - match std::io::Error::last_os_error().raw_os_error() { - Some(libc::ESRCH) => KillResult::AlreadyDead, - Some(libc::EPERM) => KillResult::PermissionDenied, - _ => KillResult::AlreadyDead, - } + use crate::sys::process::GroupSignal; + let kill_result = match crate::sys::process::terminate_group(pid as u32) { + GroupSignal::Sent => KillResult::Sent, + GroupSignal::PermissionDenied => KillResult::PermissionDenied, + GroupSignal::NotFound | GroupSignal::Other => KillResult::AlreadyDead, }; (kill_result, pane_closed) From aa10173374ae53d7856b9f2a999f7bfbeb4b2e2d Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 00:29:07 +0900 Subject: [PATCH 004/112] chore(main): gate PTY-only imports to keep the Windows build clean Context/FromStr are only used by the Unix run_pty; gate them behind cfg(unix) so the Windows build has no unused-import warnings. Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 7d2dc07e..437099d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,8 +40,12 @@ pub mod transcript; mod tui; mod update; -use anyhow::{Context, Result, bail}; +use anyhow::{Result, bail}; use std::panic; +// Used only by the Unix PTY wrapper (run_pty). +#[cfg(unix)] +use anyhow::Context; +#[cfg(unix)] use std::str::FromStr; fn main() -> Result<()> { From 125590d9bef29fe9e63754b09d5a846900a7d7db Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 00:30:16 +0900 Subject: [PATCH 005/112] chore(terminal): drop redundant u32 cast flagged by clippy Co-Authored-By: Claude Opus 4.8 --- src/terminal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal.rs b/src/terminal.rs index 4597c575..79f246ec 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1837,7 +1837,7 @@ pub fn kill_process( // SIGTERM the process group use crate::sys::process::GroupSignal; - let kill_result = match crate::sys::process::terminate_group(pid as u32) { + let kill_result = match crate::sys::process::terminate_group(pid) { GroupSignal::Sent => KillResult::Sent, GroupSignal::PermissionDenied => KillResult::PermissionDenied, GroupSignal::NotFound | GroupSignal::Other => KillResult::AlreadyDead, From 430090465d16ead008ea2fd14f6127121c98390f Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 06:59:44 +0900 Subject: [PATCH 006/112] ci: add Windows build job Add a windows-latest job that builds the crate and compiles all test targets. The Unix PTY wrapper is gated off on Windows (Phase 1); running the full test suite on Windows is a follow-up once Windows runtime paths are validated on a real host, so this job asserts the build/compile only. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d0934a8..5c687fa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,19 @@ jobs: - run: cargo clippy --all-targets --locked -- -D warnings - run: cargo test --locked + # Windows is Phase 1: the crate compiles and links, with the Unix PTY wrapper + # gated off (run_pty returns a clear "not supported" error). We assert the + # build and that all test targets compile; running the full suite on Windows + # is a follow-up once the remaining Windows runtime paths are validated. + windows-build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2.9.1 + - run: cargo build --locked + - run: cargo test --locked --no-run + real-tool-tests: name: Real tool (${{ matrix.tool }}) runs-on: ubuntu-latest From c3fb245e9975e997cf59d3959278bc49edef888c Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 07:02:26 +0900 Subject: [PATCH 007/112] ci: trigger run after enabling Actions Co-Authored-By: Claude Opus 4.8 From ce8d54af8e73333553230b06e75dc15373831b69 Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 07:18:44 +0900 Subject: [PATCH 008/112] =?UTF-8?q?ci:=20fix=20Windows=20CI=20=E2=80=94=20?= =?UTF-8?q?format=20sys=20code,=20gate=20Unix-only=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run rustfmt over the cfg(windows) bodies in sys/net.rs and sys/process.rs that fmt --check flagged (formatted regardless of target). - Gate the ExitStatusExt-based terminal tests with #[cfg(unix)] so the bin unit tests compile on Windows. - Scope the windows-build test-compile to --bins; the tests/ integration suite is Unix-only (process groups, real-tool lifecycle) and is not compiled on Windows in Phase 1. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 8 +++++--- src/sys/net.rs | 10 +--------- src/sys/process.rs | 4 +--- src/terminal.rs | 3 +++ 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c687fa8..66ec9d5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,10 @@ jobs: # Windows is Phase 1: the crate compiles and links, with the Unix PTY wrapper # gated off (run_pty returns a clear "not supported" error). We assert the - # build and that all test targets compile; running the full suite on Windows - # is a follow-up once the remaining Windows runtime paths are validated. + # build and that the bin unit tests compile. The integration tests under + # tests/ are Unix-only (process groups, real-tool lifecycle) and are not + # compiled here; running the full suite on Windows is a follow-up once the + # remaining Windows runtime paths are validated. windows-build: runs-on: windows-latest steps: @@ -26,7 +28,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2.9.1 - run: cargo build --locked - - run: cargo test --locked --no-run + - run: cargo test --locked --no-run --bins real-tool-tests: name: Real tool (${{ matrix.tool }}) diff --git a/src/sys/net.rs b/src/sys/net.rs index 84f439c5..7e53d7c9 100644 --- a/src/sys/net.rs +++ b/src/sys/net.rs @@ -35,15 +35,7 @@ pub fn wait_readable(listener: &TcpListener, timeout: Duration) -> bool { tv_usec: timeout.subsec_micros() as i32, }; // SAFETY: read set holds one valid socket; nfds is ignored on Windows. - let ret = unsafe { - select( - 0, - &mut set, - std::ptr::null_mut(), - std::ptr::null_mut(), - &tv, - ) - }; + let ret = unsafe { select(0, &mut set, std::ptr::null_mut(), std::ptr::null_mut(), &tv) }; ret > 0 } } diff --git a/src/sys/process.rs b/src/sys/process.rs index 637d123f..f484a47c 100644 --- a/src/sys/process.rs +++ b/src/sys/process.rs @@ -209,9 +209,7 @@ pub fn detach_session(command: &mut Command) { #[cfg(windows)] fn terminate_win(pid: u32) -> bool { use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::Threading::{ - OpenProcess, PROCESS_TERMINATE, TerminateProcess, - }; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; // SAFETY: opens a terminate-only handle, closes it before returning. unsafe { let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); diff --git a/src/terminal.rs b/src/terminal.rs index 79f246ec..03d9c72f 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1933,6 +1933,7 @@ fn zellij_pane_id_from_terminal_id(terminal_id: &str) -> Option { mod tests { use super::*; use serial_test::serial; + #[cfg(unix)] use std::os::unix::process::ExitStatusExt; struct EnvGuard(Vec<(&'static str, Option)>); @@ -1984,6 +1985,7 @@ mod tests { } #[test] + #[cfg(unix)] fn test_termux_dispatch_rejects_nonzero_exit_status() { let status = std::process::ExitStatus::from_raw(1 << 8); let err = validate_termux_dispatch_status(status) @@ -2089,6 +2091,7 @@ mod tests { } #[test] + #[cfg(unix)] fn test_zellij_session_ambiguity_stderr_fails_launch_even_with_exit_zero() { let output = std::process::Output { status: std::process::ExitStatus::from_raw(0), From 243a78054fcd43a64064fe75a492b5fe2e29b238 Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 08:04:39 +0900 Subject: [PATCH 009/112] feat(windows): add PowerShell launch script generator Phase 2 / M1 step 1. Add create_powershell_script as the Windows-native counterpart to create_bash_script: it emits a .ps1 that sets the window title, scrubs inherited tool/identity vars, prepends discovered tool dirs to PATH (;-separated), assigns the per-launch env ($env:K = 'V'), changes directory, and runs the tool via the call operator. Window-open vs run-once cleanup mirrors the bash version (window persists via the forthcoming powershell -NoExit invocation). launch_terminal now selects the PowerShell generator and a .ps1 extension on Windows (cfg!(windows) runtime branch so both arms compile everywhere). The launch *invocation* is still bash-only and is wired up in the next step; this commit only changes the generated script content. Pure string generation, covered by unit tests that run on macOS/Linux. Co-Authored-By: Claude Opus 4.8 --- src/terminal.rs | 284 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 259 insertions(+), 25 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index 03d9c72f..4d3a1955 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -673,19 +673,10 @@ fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -/// Create a bash script for terminal launch. -/// -/// Scripts provide uniform execution across all platforms/terminals. -pub fn create_bash_script( - script_file: &Path, - env: &HashMap, - cwd: Option<&str>, - command_str: &str, - background: bool, - tool_name: Option<&str>, - opens_new_window: bool, -) -> Result<()> { - let tool_name = tool_name.unwrap_or_else(|| { +/// Resolve a human-readable tool name for the launch script title/banner, +/// detecting from the command when not explicitly provided. +fn launch_display_name<'a>(command_str: &str, tool_name: Option<&'a str>) -> &'a str { + tool_name.unwrap_or_else(|| { let cmd_lower = command_str.to_lowercase(); if cmd_lower.contains("opencode") { "OpenCode" @@ -702,7 +693,22 @@ pub fn create_bash_script( } else { "hcom" } - }); + }) +} + +/// Create a bash script for terminal launch. +/// +/// Scripts provide uniform execution across all platforms/terminals. +pub fn create_bash_script( + script_file: &Path, + env: &HashMap, + cwd: Option<&str>, + command_str: &str, + background: bool, + tool_name: Option<&str>, + opens_new_window: bool, +) -> Result<()> { + let tool_name = launch_display_name(command_str, tool_name); let mut f = fs::File::create(script_file).context("Failed to create script file")?; @@ -803,6 +809,151 @@ pub fn create_bash_script( Ok(()) } +/// Quote a string as a PowerShell single-quoted literal (embedded `'` doubled). +fn ps_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +/// Build sorted `$env:K = 'V'` assignments, applying the same key validation +/// as `build_env_string` so only well-formed names are emitted. +fn ps_env_assignments(env_vars: &HashMap) -> Vec { + let mut valid: Vec<(&String, &String)> = env_vars + .iter() + .filter(|(k, _)| { + k.chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) + .collect(); + valid.sort_by_key(|(k, _)| k.to_string()); + valid + .iter() + .map(|(k, v)| format!("$env:{} = {}", k, ps_quote(v))) + .collect() +} + +/// Create a PowerShell launch script — the Windows-native equivalent of +/// `create_bash_script`. Emits a `.ps1` that sets the window title, scrubs +/// inherited tool/identity vars, prepends discovered tool directories to PATH, +/// assigns the per-launch env, changes directory, then runs the tool command +/// via the call operator. The window-open vs. run-once cleanup mirrors the +/// bash version; the window is kept alive by launching with `powershell -NoExit`. +pub fn create_powershell_script( + script_file: &Path, + env: &HashMap, + cwd: Option<&str>, + command_str: &str, + background: bool, + tool_name: Option<&str>, + opens_new_window: bool, +) -> Result<()> { + let tool_name = launch_display_name(command_str, tool_name); + + let mut f = fs::File::create(script_file).context("Failed to create script file")?; + + writeln!( + f, + "$Host.UI.RawUI.WindowTitle = \"hcom: starting {}...\"", + tool_name + )?; + writeln!(f, "Write-Host \"Starting {}...\"", tool_name)?; + + // Scrub inherited tool markers and identity vars so the child can't inherit + // them (PowerShell ignores Env: entries that don't exist). + let scrub: Vec = tool_marker_vars() + .iter() + .chain(HCOM_IDENTITY_VARS.iter()) + .map(|v| format!("Env:{v}")) + .collect(); + writeln!( + f, + "Remove-Item {} -ErrorAction SilentlyContinue", + scrub.join(",") + )?; + + // Discover paths for minimal environments. + let mut paths_to_add: Vec = Vec::new(); + + fn add_path(paths: &mut Vec, binary_path: Option) { + if let Some(bp) = binary_path + && let Some(dir) = Path::new(&bp).parent() + { + let dir_str = dir.to_string_lossy().to_string(); + if !paths.contains(&dir_str) { + paths.push(dir_str); + } + } + } + + add_path(&mut paths_to_add, which_bin("hcom")); + add_path(&mut paths_to_add, which_bin("python3")); + let cmd_stripped = command_str.trim_start(); + let tool_cmd = cmd_stripped.split_whitespace().next().unwrap_or(""); + add_path(&mut paths_to_add, which_bin(tool_cmd)); + if tool_cmd == "claude" { + add_path(&mut paths_to_add, which_bin("node")); + } + + if !paths_to_add.is_empty() { + // Windows PATH is `;`-separated. + let prefix = format!("{};", paths_to_add.join(";")); + writeln!(f, "$env:PATH = {} + $env:PATH", ps_quote(&prefix))?; + } + + for line in ps_env_assignments(env) { + writeln!(f, "{line}")?; + } + + if let Some(dir) = cwd { + writeln!(f, "Set-Location {}", ps_quote(dir))?; + } + + // Resolve the tool to a full path and invoke it through the call operator so + // a quoted path runs as a command. If the tool isn't found, fall through to + // the bare command name (resolved via the PATH we just prepended). + let mut final_command = command_str.to_string(); + if !tool_cmd.is_empty() + && let Some(tool_path) = which_bin(tool_cmd) + { + final_command = final_command.replacen( + &format!("{tool_cmd} "), + &format!("& {} ", ps_quote(&tool_path)), + 1, + ); + } + + writeln!(f, "{final_command}")?; + + if opens_new_window { + // Clear hcom state from the interactive shell left open after the tool + // exits (window persists via `powershell -NoExit`). + let mut leftover_vars: Vec<&str> = HCOM_IDENTITY_VARS.to_vec(); + leftover_vars.extend(["HCOM_TAG", "HCOM_CODEX_SANDBOX_MODE"]); + let leftover: Vec = leftover_vars.iter().map(|v| format!("Env:{v}")).collect(); + writeln!( + f, + "Remove-Item {} -ErrorAction SilentlyContinue", + leftover.join(",") + )?; + writeln!( + f, + "Remove-Item -Force -ErrorAction SilentlyContinue {}", + ps_quote(&script_file.to_string_lossy()) + )?; + } else if !background { + writeln!(f, "$hcom_status = $LASTEXITCODE")?; + writeln!( + f, + "Remove-Item -Force -ErrorAction SilentlyContinue {}", + ps_quote(&script_file.to_string_lossy()) + )?; + writeln!(f, "exit $hcom_status")?; + } + + Ok(()) +} + /// Build clean env for terminal launcher subprocesses. /// /// Strips AI tool markers, hcom identity vars, and terminal context vars. @@ -1386,7 +1537,9 @@ pub fn launch_terminal( // Determine script extension after terminal mode resolution so explicit // Terminal.app uses the macOS `.command` launcher just like auto-detect. - let extension = if should_use_command_extension(background, &terminal_mode) { + let extension = if cfg!(windows) { + ".ps1" + } else if should_use_command_extension(background, &terminal_mode) { ".command" } else { ".sh" @@ -1406,16 +1559,28 @@ pub fn launch_terminal( fs::create_dir_all(parent).ok(); } - // Create script - create_bash_script( - &script_file, - &final_env, - cwd, - command, - background, - None, - opens_new_window, - )?; + // Create script. Windows uses a native PowerShell script; Unix uses bash. + if cfg!(windows) { + create_powershell_script( + &script_file, + &final_env, + cwd, + command, + background, + None, + opens_new_window, + )?; + } else { + create_bash_script( + &script_file, + &final_env, + cwd, + command, + background, + None, + opens_new_window, + )?; + } // Background mode if background { @@ -2169,6 +2334,75 @@ mod tests { assert!(content.contains("cwd: \"/some/dir\"")); } + #[test] + fn test_ps_quote_doubles_single_quotes() { + assert_eq!(ps_quote("plain"), "'plain'"); + assert_eq!(ps_quote("it's"), "'it''s'"); + assert_eq!(ps_quote(""), "''"); + } + + #[test] + fn test_ps_env_assignments_sorted_and_validated() { + let mut env = HashMap::new(); + env.insert("ZED".to_string(), "z".to_string()); + env.insert("ABE".to_string(), "a'b".to_string()); + env.insert("1bad".to_string(), "skip".to_string()); // invalid name dropped + let lines = ps_env_assignments(&env); + assert_eq!( + lines, + vec![ + "$env:ABE = 'a''b'".to_string(), + "$env:ZED = 'z'".to_string(), + ] + ); + } + + #[test] + fn test_create_powershell_script_window_mode() { + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("launch.ps1"); + let mut env = HashMap::new(); + env.insert("HCOM_TOOL".to_string(), "claude".to_string()); + create_powershell_script( + &script, + &env, + Some("/work/dir"), + "claude --foo", + false, // background + None, + true, // opens_new_window + ) + .unwrap(); + let content = std::fs::read_to_string(&script).unwrap(); + assert!(content.contains("$Host.UI.RawUI.WindowTitle = \"hcom: starting Claude Code...\"")); + assert!(content.contains("Write-Host \"Starting Claude Code...\"")); + assert!(content.contains("Remove-Item Env:")); + assert!(content.contains("$env:HCOM_TOOL = 'claude'")); + assert!(content.contains("Set-Location '/work/dir'")); + // The command args survive whether or not the tool resolved to a full + // path (bare `claude --foo` or call-operator `& '' --foo`). + assert!(content.contains("--foo")); + // Window mode self-deletes but does not `exit` (window persists via -NoExit). + assert!(content.contains("Remove-Item -Force -ErrorAction SilentlyContinue")); + assert!(!content.contains("exit $hcom_status")); + } + + #[test] + fn test_create_powershell_script_run_once_exits() { + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("launch.ps1"); + let env = HashMap::new(); + create_powershell_script( + &script, &env, None, "codex", false, // background + None, false, // run-once (not a new window) + ) + .unwrap(); + let content = std::fs::read_to_string(&script).unwrap(); + assert!(content.contains("$hcom_status = $LASTEXITCODE")); + assert!(content.contains("exit $hcom_status")); + assert!(!content.contains("Set-Location")); + } + #[test] fn test_resolve_warp_cwd_keeps_absolute() { let home = Path::new("/h"); From d17f822d428ea592e6c1fa8eb6947274c23b1171 Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 08:09:39 +0900 Subject: [PATCH 010/112] feat(windows): launch agents via PowerShell instead of bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 / M1 step 2 — the launch invocation side. - which_bin: make cross-platform. Split PATH with std::env::split_paths (so ';' separates and Windows 'C:' drive letters aren't split) and, on Windows, probe PATHEXT extensions (.exe/.cmd/...) for an extension-less name. Unix behavior is unchanged. - get_windows_terminal_argv: default Windows launch prefers Windows Terminal ("wt -- powershell -ExecutionPolicy Bypass -NoExit -File {script}"), falling back to a fresh console via "cmd /c start". Wires the previously-bailing "Windows" arm of the platform-default match. - windows_shellify_preset: rewrite a preset's "bash {script}" to the PowerShell invocation on Windows (wezterm/wt), leaving mintty (Git Bash) alone. Applied to the resolved custom command template. Both new helpers are referenced through cfg!(windows)/runtime match arms so they compile on every target. Unit tests cover the preset rewrite. Co-Authored-By: Claude Opus 4.8 --- src/terminal.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index 4d3a1955..aa17bef9 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -392,13 +392,40 @@ pub fn wezterm_reachable() -> bool { .unwrap_or(false) } +/// Candidate file names to probe for `name` in a PATH directory. On Windows an +/// extension-less name is expanded with PATHEXT (`.exe`, `.cmd`, …); elsewhere +/// the name is used verbatim. +fn which_candidates(dir: &Path, name: &str) -> Vec { + #[cfg(windows)] + { + if Path::new(name).extension().is_some() { + return vec![dir.join(name)]; + } + let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + let mut out: Vec = exts + .split(';') + .filter(|e| !e.is_empty()) + .map(|ext| dir.join(format!("{name}{ext}"))) + .collect(); + out.push(dir.join(name)); + out + } + #[cfg(not(windows))] + { + vec![dir.join(name)] + } +} + /// Simple `which` implementation — find binary in PATH. pub fn which_bin(name: &str) -> Option { - let path_var = std::env::var("PATH").ok()?; - for dir in path_var.split(':') { - let candidate = Path::new(dir).join(name); - if candidate.exists() && candidate.is_file() { - return Some(candidate.to_string_lossy().to_string()); + let path_var = std::env::var_os("PATH")?; + // `split_paths` uses the platform separator (`;` on Windows, `:` elsewhere), + // which also avoids splitting Windows drive letters like `C:`. + for dir in std::env::split_paths(&path_var) { + for candidate in which_candidates(&dir, name) { + if candidate.is_file() { + return Some(candidate.to_string_lossy().to_string()); + } } } @@ -1272,6 +1299,55 @@ fn get_linux_terminal_argv() -> Option> { None } +/// Default Windows terminal launch argv ({script} substituted by the caller). +/// +/// Prefers Windows Terminal, which parses everything after `--` as a literal +/// argv so a script path with spaces stays a single argument. Without it, opens +/// a fresh console via cmd's `start` (the empty arg is start's window-title +/// slot; the path is quoted so start keeps spaces together). Either way the +/// shell runs the generated `.ps1` with the execution policy bypassed and stays +/// open (`-NoExit`) for the new window. +fn get_windows_terminal_argv() -> Vec { + let to_vec = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + if which_bin("wt").is_some() { + return to_vec(&[ + "wt", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ]); + } + to_vec(&[ + "cmd", + "/c", + "start", + "", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "\"{script}\"", + ]) +} + +/// On Windows, rewrite a preset's bash invocation to run the generated `.ps1` +/// via PowerShell. Inherently-bash terminals (mintty, shipped with Git Bash) +/// are left untouched. +fn windows_shellify_preset(template: &str) -> String { + if template.starts_with("mintty") { + return template.to_string(); + } + template.replace( + "bash {script}", + "powershell -ExecutionPolicy Bypass -NoExit -File {script}", + ) +} + /// Spawn terminal process, detached when inside AI tools. /// /// Returns (success, stdout_first_line) — stdout captured for {id} in close commands. @@ -1729,6 +1805,12 @@ pub fn launch_terminal( .map(|s| s.as_str()) .filter(|s| !s.is_empty()), }; + // On Windows, run the generated .ps1 via PowerShell instead of bash. + let cmd_template = if cfg!(windows) { + windows_shellify_preset(&cmd_template) + } else { + cmd_template + }; let final_argv = parse_terminal_command(&cmd_template, ctx)?; let (success, captured_id) = spawn_terminal_process(&final_argv, inside_ai_tool)?; write_terminal_id(env, &captured_id); @@ -1788,6 +1870,7 @@ pub fn launch_terminal( )?, "Linux" => get_linux_terminal_argv() .ok_or_else(|| anyhow::anyhow!("No supported terminal emulator found"))?, + "Windows" => get_windows_terminal_argv(), other => bail!("Unsupported platform: {}", other), }; @@ -2403,6 +2486,23 @@ mod tests { assert!(!content.contains("Set-Location")); } + #[test] + fn test_windows_shellify_preset_rewrites_bash() { + assert_eq!( + windows_shellify_preset("wezterm start -- bash {script}"), + "wezterm start -- powershell -ExecutionPolicy Bypass -NoExit -File {script}" + ); + assert_eq!( + windows_shellify_preset("wt -- bash {script}"), + "wt -- powershell -ExecutionPolicy Bypass -NoExit -File {script}" + ); + // mintty ships with Git Bash, so its bash invocation is left intact. + assert_eq!( + windows_shellify_preset("mintty bash {script}"), + "mintty bash {script}" + ); + } + #[test] fn test_resolve_warp_cwd_keeps_absolute() { let home = Path::new("/h"); From c1d02653c4f90ee3a9761517dadbba57b1a8e241 Mon Sep 17 00:00:00 2001 From: keinstn Date: Fri, 26 Jun 2026 08:13:17 +0900 Subject: [PATCH 011/112] feat(windows): run background/run-here launches via PowerShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 / M1 step 3 — make the remaining launch modes consistent with the .ps1 script the generator now emits on Windows. The new-window path was already wired (step 2), but background and run-here still invoked the script through bash. On Windows they now run it through "powershell -ExecutionPolicy Bypass -File