From 76771bed0d8838f1928e80b311a7b961e4e014a2 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 06:02:28 +0200 Subject: [PATCH] feat(windows): port Ghostty's Windows code paths Ports the Windows branches of the Zig code behind cfg(windows): ConPTY pty (CreatePseudoConsole, pipes, resize), CreateProcessW with the pseudo-console attribute list, env blocks and command-line quoting, a pipe read thread with CancelIoEx and a WaitForMultipleObjects IO loop, plus os helpers (home dir, env, paths, temp dirs, hostname, XDG via LOCALAPPDATA) and config/input/terminal/crash Windows branches. Compile-checked with `cargo check --target x86_64-pc-windows-gnu` for vt, unicode, datastruct, input, os, config, terminal and termio (setup in docs/rewrite/notes/windows-WIN.md); cannot run here. Platform-independent pieces (quoting with a CommandLineToArgvW round-trip, env block encoding) have unit tests. Test Plan: - cargo test -p ghostty-termio windows_args - cargo check --target x86_64-pc-windows-gnu -p ghostty-termio --- crates/ghostty-os/src/windows.rs | 96 ++ crates/ghostty-termio/src/command_windows.rs | 342 +++++++ crates/ghostty-termio/src/exec_windows.rs | 948 +++++++++++++++++++ crates/ghostty-termio/src/pty_windows.rs | 348 +++++++ crates/ghostty-termio/src/thread_windows.rs | 151 +++ crates/ghostty-termio/src/wakeup_windows.rs | 122 +++ crates/ghostty-termio/src/windows_args.rs | 290 ++++++ docs/rewrite/notes/windows-WIN.md | 105 ++ 8 files changed, 2402 insertions(+) create mode 100644 crates/ghostty-os/src/windows.rs create mode 100644 crates/ghostty-termio/src/command_windows.rs create mode 100644 crates/ghostty-termio/src/exec_windows.rs create mode 100644 crates/ghostty-termio/src/pty_windows.rs create mode 100644 crates/ghostty-termio/src/thread_windows.rs create mode 100644 crates/ghostty-termio/src/wakeup_windows.rs create mode 100644 crates/ghostty-termio/src/windows_args.rs create mode 100644 docs/rewrite/notes/windows-WIN.md diff --git a/crates/ghostty-os/src/windows.rs b/crates/ghostty-os/src/windows.rs new file mode 100644 index 0000000..6f8fea6 --- /dev/null +++ b/crates/ghostty-os/src/windows.rs @@ -0,0 +1,96 @@ +//! Windows API helpers (`os/windows.zig`). Zig re-exports a handful of +//! kernel32 items and declares the ConPTY externs that `std` lacks; here the +//! `windows-sys` crate provides all of those, so this module only holds the +//! small shared helpers plus the constants Zig computes by hand. + +/// Re-export so dependants can name Windows API items through one path. +pub use windows_sys as sys; + +/// `ProcThreadAttributeNumber.ProcThreadAttributePseudoConsole`. +const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE_NUMBER: u32 = 22; +const PROC_THREAD_ATTRIBUTE_NUMBER: u32 = 0x0000_FFFF; +const PROC_THREAD_ATTRIBUTE_THREAD: u32 = 0x0001_0000; +const PROC_THREAD_ATTRIBUTE_INPUT: u32 = 0x0002_0000; +const PROC_THREAD_ATTRIBUTE_ADDITIVE: u32 = 0x0004_0000; + +/// Corresponds to the `ProcThreadAttributeValue` define in WinBase.h. +pub const fn proc_thread_attribute_value( + number: u32, + thread: bool, + input: bool, + additive: bool, +) -> u32 { + (number & PROC_THREAD_ATTRIBUTE_NUMBER) + | if thread { + PROC_THREAD_ATTRIBUTE_THREAD + } else { + 0 + } + | if input { + PROC_THREAD_ATTRIBUTE_INPUT + } else { + 0 + } + | if additive { + PROC_THREAD_ATTRIBUTE_ADDITIVE + } else { + 0 + } +} + +/// `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` (`windows.exp`). +pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: u32 = proc_thread_attribute_value( + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE_NUMBER, + false, + true, + false, +); + +/// `CREATE_UNICODE_ENVIRONMENT`. +pub const CREATE_UNICODE_ENVIRONMENT: u32 = 0x0000_0400; +/// `EXTENDED_STARTUPINFO_PRESENT`. +pub const EXTENDED_STARTUPINFO_PRESENT: u32 = 0x0008_0000; +/// `FILE_FLAG_FIRST_PIPE_INSTANCE`. +pub const FILE_FLAG_FIRST_PIPE_INSTANCE: u32 = 0x0008_0000; +/// `STATUS_PENDING` / `STILL_ACTIVE`. +pub const STILL_ACTIVE: u32 = 0x0000_0103; + +/// UTF-8 to a NUL-terminated UTF-16 buffer (`utf8ToUtf16LeAllocZ`). +pub fn to_wide_nul(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// The NetBIOS name of this computer (`GetComputerNameA`), if available. +#[allow(unsafe_code)] +pub fn computer_name() -> Option { + use windows_sys::Win32::System::WindowsProgramming::GetComputerNameA; + + let mut buf = [0u8; 257]; + let mut size = 256u32; + // SAFETY: `buf` is valid for `size` + 1 bytes and `size` is a valid + // in/out pointer. + if unsafe { GetComputerNameA(buf.as_mut_ptr(), &raw mut size) } == 0 { + return None; + } + Some(String::from_utf8_lossy(&buf[..size as usize]).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pseudoconsole_attribute_value() { + // Matches the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE define (0x20016). + assert_eq!(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, 0x0002_0016); + assert_eq!( + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + windows_sys::Win32::System::Threading::PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE + ); + } + + #[test] + fn wide_is_nul_terminated() { + assert_eq!(to_wide_nul("ab"), vec![b'a'.into(), b'b'.into(), 0]); + } +} diff --git a/crates/ghostty-termio/src/command_windows.rs b/crates/ghostty-termio/src/command_windows.rs new file mode 100644 index 0000000..1ff9318 --- /dev/null +++ b/crates/ghostty-termio/src/command_windows.rs @@ -0,0 +1,342 @@ +//! Command launches sub-processes (Windows branch of `Command.zig`: +//! `startWindows` and the Windows `wait`). Uses `CreateProcessW`, attaching +//! the child to a ConPTY pseudo console through a process thread attribute +//! list when one is set. +//! +//! The pure parts (command line quoting, environment block) are in +//! [`crate::windows_args`]. + +// Win32 FFI takes out-parameters as `*mut`; implicit `&mut` -> pointer coercions +// are the idiomatic spelling at these call sites. +#![allow(clippy::borrow_as_ptr)] + +use std::{ + io, + os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle, RawHandle}, + ptr, +}; + +use ghostty_os::windows::{ + CREATE_UNICODE_ENVIRONMENT, + EXTENDED_STARTUPINFO_PRESENT, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + to_wide_nul, +}; +use windows_sys::Win32::{ + Foundation::{HANDLE, TRUE, WAIT_FAILED}, + System::{ + Console::HPCON, + Threading::{ + CreateProcessW, + DeleteProcThreadAttributeList, + GetExitCodeProcess, + INFINITE, + InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, + PROCESS_INFORMATION, + STARTF_USESTDHANDLES, + STARTUPINFOEXW, + TerminateProcess, + UpdateProcThreadAttribute, + WaitForSingleObject, + }, + }, +}; + +use crate::windows_args::{self, create_windows_env_block, windows_create_command_line}; + +/// Errors from [`Command::start`]. +#[derive(Debug, thiserror::Error)] +pub enum StartError { + #[error("string or environment is not valid unicode")] + Encode(#[from] windows_args::EncodeError), + #[error("windows error: {0}")] + Os(#[from] io::Error), +} + +/// How a process exited. On Windows this is just the exit code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Exit { + Exited(u32), +} + +/// A command to launch. +#[derive(Default)] +pub struct Command { + /// Path to the command to run (`lpApplicationName`). + pub path: String, + + /// Command-line arguments. It is the responsibility of the caller to set + /// args[0] to the command. If empty, no command line is passed. + pub args: Vec, + + /// Environment variables for the child process. If this is `None`, + /// inherits the environment variables from this process. These are the + /// exact environment variables to set; these are /not/ merged. + pub env: Option, + + /// Working directory for the child. If not set, the working directory of + /// the calling process is preserved. + pub cwd: Option, + + /// The file handles to set for stdin/out/err. If not set the NUL device + /// is used. Ignored if `pseudo_console` is set. + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, + + /// If set, then the process will be created attached to this pseudo + /// console. `stdin`, `stdout`, and `stderr` will be ignored if set. + pub pseudo_console: Option, + + /// Process handle (Zig stores it in `pid`), set after start is called. + process: Option, + + /// Process ID, set after start is called. + pub pid: Option, +} + +impl std::fmt::Debug for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Command") + .field("path", &self.path) + .field("args", &self.args) + .field("cwd", &self.cwd) + .field("pid", &self.pid) + .finish_non_exhaustive() + } +} + +impl Command { + pub fn new(path: impl Into, args: &[&str]) -> Self { + Self { + path: path.into(), + args: args.iter().map(|s| (*s).to_owned()).collect(), + ..Self::default() + } + } + + /// The process handle, which becomes signaled when the process exits. + pub fn process_handle(&self) -> Option> { + use std::os::windows::io::AsHandle; + self.process.as_ref().map(AsHandle::as_handle) + } + + /// Start the subprocess. This returns immediately once the child is + /// started. After this is successful, `pid` and the process handle are + /// available. + #[allow(unsafe_code)] + pub fn start(&mut self) -> Result<(), StartError> { + let application_w = to_wide_nul(&self.path); + let cwd_w = self.cwd.as_deref().map(to_wide_nul); + let mut command_line_w = if self.args.is_empty() { + None + } else { + Some(to_wide_nul(&windows_create_command_line(&self.args))) + }; + let mut env_w = match &self.env { + Some(map) => Some(create_windows_env_block(map.iter())?), + None => None, + }; + + // If any stdio handle is missing (and we're not using a pseudo + // console) the child gets the NUL device for it. + let any_null_fd = self.stdin.is_none() || self.stdout.is_none() || self.stderr.is_none(); + let null_fd = if any_null_fd && self.pseudo_console.is_none() { + Some( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("NUL")?, + ) + } else { + None + }; + + // TODO (as in Zig): in the case of having handles instead of a pty, + // set up attributes such that the child process only inherits these + // handles, then set bInheritsHandles below. + + // The attribute list buffer must outlive CreateProcessW. A u64 buffer + // guarantees pointer alignment. + let mut attribute_buf: Vec = Vec::new(); + let attribute_list: LPPROC_THREAD_ATTRIBUTE_LIST = + if let Some(pseudo_console) = self.pseudo_console { + let mut size: usize = 0; + // SAFETY: querying the required size with a null list is the + // documented use; it is expected to "fail" with the size set. + unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut size) }; + attribute_buf.resize(size.div_ceil(8), 0); + let list: LPPROC_THREAD_ATTRIBUTE_LIST = attribute_buf.as_mut_ptr().cast(); + // SAFETY: the buffer is at least `size` bytes and 8-aligned. + if unsafe { InitializeProcThreadAttributeList(list, 1, 0, &mut size) } == 0 { + return Err(io::Error::last_os_error().into()); + } + // SAFETY: the list is initialized; the pseudo console handle is + // passed by value as the attribute value, as the API requires. + if unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + pseudo_console as *const std::ffi::c_void, + std::mem::size_of::(), + ptr::null_mut(), + ptr::null(), + ) + } == 0 + { + let err = io::Error::last_os_error(); + // SAFETY: initialized above. + unsafe { DeleteProcThreadAttributeList(list) }; + return Err(err.into()); + } + list + } else { + ptr::null_mut() + }; + + let handle_of = |h: &Option| -> HANDLE { + match (h, &null_fd) { + (Some(h), _) => h.as_raw_handle() as HANDLE, + (None, Some(f)) => f.as_raw_handle() as HANDLE, + (None, None) => ptr::null_mut(), + } + }; + let (stdin, stdout, stderr) = if attribute_list.is_null() { + ( + handle_of(&self.stdin), + handle_of(&self.stdout), + handle_of(&self.stderr), + ) + } else { + (ptr::null_mut(), ptr::null_mut(), ptr::null_mut()) + }; + + // SAFETY: STARTUPINFOEXW is plain data; all-zero is a valid value. + let mut startup_info_ex: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup_info_ex.StartupInfo.cb = if attribute_list.is_null() { + std::mem::size_of::() as u32 + } else { + std::mem::size_of::() as u32 + }; + startup_info_ex.StartupInfo.hStdError = stderr; + startup_info_ex.StartupInfo.hStdOutput = stdout; + startup_info_ex.StartupInfo.hStdInput = stdin; + startup_info_ex.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup_info_ex.lpAttributeList = attribute_list; + + let mut flags = CREATE_UNICODE_ENVIRONMENT; + if !attribute_list.is_null() { + flags |= EXTENDED_STARTUPINFO_PRESENT; + } + + // SAFETY: PROCESS_INFORMATION is plain data. + let mut process_information: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: every pointer is either null or points at a NUL-terminated + // (or properly terminated environment) buffer that outlives the call; + // the command line buffer is mutable as CreateProcessW requires. + let ok = unsafe { + CreateProcessW( + application_w.as_ptr(), + command_line_w + .as_mut() + .map_or(ptr::null_mut(), Vec::as_mut_ptr), + ptr::null(), + ptr::null(), + TRUE, + flags, + env_w + .as_mut() + .map_or(ptr::null(), |w| w.as_mut_ptr().cast_const().cast()), + cwd_w.as_ref().map_or(ptr::null(), Vec::as_ptr), + ptr::addr_of!(startup_info_ex.StartupInfo), + &mut process_information, + ) + }; + let err = (ok == 0).then(io::Error::last_os_error); + if !attribute_list.is_null() { + // SAFETY: initialized above; no longer needed after the call. + unsafe { DeleteProcThreadAttributeList(attribute_list) }; + } + if let Some(err) = err { + return Err(err.into()); + } + + // SAFETY: CreateProcessW succeeded, so both handles are valid and + // owned by us. Zig leaks the thread handle; we close it. + let (process, _thread) = unsafe { + ( + OwnedHandle::from_raw_handle(process_information.hProcess as RawHandle), + OwnedHandle::from_raw_handle(process_information.hThread as RawHandle), + ) + }; + self.process = Some(process); + self.pid = Some(process_information.dwProcessId); + Ok(()) + } + + /// Wait for the command to exit and return information about how it + /// exited. Like Zig this always blocks until the process exits (`block` + /// is ignored on Windows); it returns immediately if the process already + /// exited. + #[allow(unsafe_code)] + pub fn wait(&self, _block: bool) -> io::Result { + let handle = self + .process + .as_ref() + .ok_or_else(|| io::Error::other("process not started"))? + .as_raw_handle() as HANDLE; + + // SAFETY: the handle is valid while `self` is alive. + if unsafe { WaitForSingleObject(handle, INFINITE) } == WAIT_FAILED { + return Err(io::Error::last_os_error()); + } + self.exit_code().map(Exit::Exited) + } + + /// The exit code of the (already exited) process (`GetExitCodeProcess`). + #[allow(unsafe_code)] + pub fn exit_code(&self) -> io::Result { + let handle = self + .process + .as_ref() + .ok_or_else(|| io::Error::other("process not started"))? + .as_raw_handle() as HANDLE; + let mut code: u32 = 0; + // SAFETY: valid handle and out pointer. + if unsafe { GetExitCodeProcess(handle, &mut code) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(code) + } + + /// Terminate the process (`TerminateProcess(pid, 0)`). + #[allow(unsafe_code)] + pub fn terminate(&self) -> io::Result<()> { + let Some(process) = &self.process else { + return Ok(()); + }; + // SAFETY: valid handle. + if unsafe { TerminateProcess(process.as_raw_handle() as HANDLE, 0) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_cmd_and_wait() { + let mut cmd = Command::new( + "C:\\Windows\\System32\\cmd.exe", + &["cmd.exe", "/c", "exit 7"], + ); + cmd.start().unwrap(); + assert!(cmd.pid.is_some()); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(7)); + } +} diff --git a/crates/ghostty-termio/src/exec_windows.rs b/crates/ghostty-termio/src/exec_windows.rs new file mode 100644 index 0000000..952dd45 --- /dev/null +++ b/crates/ghostty-termio/src/exec_windows.rs @@ -0,0 +1,948 @@ +//! Exec implements the logic for starting and stopping a subprocess with a +//! ConPTY pseudo console as well as spinning up the necessary read thread to +//! read from the pty and forward it to the Termio instance +//! (`termio/Exec.zig`, Windows branches). Windows variant of [`crate::exec`]. +//! +//! # Event loop mapping +//! +//! libxev completions are replaced by state in [`ThreadData`] that the IO +//! thread's `WaitForMultipleObjects` loop (see [`crate::thread`]) consults: +//! +//! * process exit: the process handle (signaled when the process exits), +//! * pty writes: a dedicated writer thread draining a queue of byte chunks +//! with blocking overlapped `WriteFile` calls (the input pipe is a named +//! pipe opened for overlapped I/O, as in Zig), +//! * the termios timer does not exist on Windows (Zig does not run it +//! either). + +// Win32 FFI takes out-parameters as `*mut`; implicit `&mut` -> pointer coercions +// are the idiomatic spelling at these call sites. +#![allow(clippy::borrow_as_ptr)] + +use std::{ + io, + os::windows::io::{AsRawHandle, OwnedHandle}, + sync::Arc, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +use crossbeam_channel::{Receiver, Sender}; +use ghostty_apprt::surface::{ChildExited, Message as SurfaceMessage}; +use ghostty_config::{ + command::Command as ConfigCommand, + repeatable_string_map::RepeatableStringMap, + types_b::{ShellIntegration, ShellIntegrationFeatures}, +}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::size::{GridSize, ScreenSize}; +use ghostty_terminal::terminal::Terminal; +use windows_sys::Win32::{ + Foundation::{ + ERROR_BROKEN_PIPE, + ERROR_IO_PENDING, + ERROR_NO_DATA, + ERROR_OPERATION_ABORTED, + ERROR_PIPE_NOT_CONNECTED, + FALSE, + GetLastError, + HANDLE, + TRUE, + }, + Storage::FileSystem::{ReadFile, WriteFile}, + System::{ + IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}, + Pipes::PeekNamedPipe, + Threading::CreateEventW, + }, +}; + +/// `build_config.version_string`. +pub use crate::stream_handler::VERSION_STRING; +use crate::{ + command::{self, Command, Exit}, + pty::{self, Pty, Winsize}, + shell_integration::{self, EnvMap, Shell}, + sinks::SurfaceMailbox, + state::SharedState, + termio::Output, + windows_args::windows_shell_args, +}; + +/// Errors from starting and running the exec backend. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Pty(#[from] pty::Error), + #[error(transparent)] + Start(#[from] command::StartError), + #[error(transparent)] + ShellIntegration(#[from] shell_integration::Error), + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("failed to get WINDIR, cannot run shell command")] + SystemError, + #[error("process not started")] + ProcessNotStarted, + #[error("process has no pid")] + ProcessNoPid, +} + +/// Configuration for the exec backend (`Exec.Config`). +/// +/// The POSIX-only fields (`rt_pre_exec_info`, `rt_post_fork_info`, +/// `create_scope`, `restore_rlimit`) do not exist on Windows. +pub struct Config { + pub command: Option, + pub env: EnvMap, + pub env_override: RepeatableStringMap, + pub shell_integration: ShellIntegration, + pub shell_integration_features: ShellIntegrationFeatures, + pub cursor_blink: Option, + pub working_directory: Option, + pub resources_dir: Option, + pub term: String, +} + +impl Config { + /// A config with Zig-struct-style defaults for everything optional. + pub fn new(env: EnvMap, term: impl Into) -> Config { + Config { + command: None, + env, + env_override: RepeatableStringMap::default(), + shell_integration: ShellIntegration::Detect, + shell_integration_features: ShellIntegrationFeatures::default(), + cursor_blink: None, + working_directory: None, + resources_dir: None, + term: term.into(), + } + } +} + +/// The exec backend. Owns the (not yet started) subprocess state. +pub struct Exec { + subprocess: Subprocess, +} + +impl Exec { + /// Initialize the exec state. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + pub fn new(cfg: Config) -> Result { + Ok(Exec { + subprocess: Subprocess::new(cfg)?, + }) + } + + /// The working directory the subprocess will be started in, if any. + pub fn cwd(&self) -> Option<&str> { + self.subprocess.cwd.as_deref() + } + + /// The argv that will be executed. + pub fn args(&self) -> &[String] { + &self.subprocess.args + } + + /// The environment the subprocess will get (before it is started). + pub fn env(&self) -> Option<&EnvMap> { + self.subprocess.env.as_ref() + } + + /// Call to initialize the terminal state as necessary for this backend. + /// This is called before any termio begins. + pub fn init_terminal(&mut self, term: &mut Terminal) { + // If we have an initial pwd requested by the subprocess, then we + // set that on the terminal now. This allows rapidly initializing + // new surfaces to use the proper pwd. + if let Some(cwd) = &self.subprocess.cwd + && let Err(err) = term.set_pwd(cwd.as_bytes()) + { + log::warn!("error setting initial pwd err={err:?}"); + } + + // Setup our initial grid/screen size from the terminal. This + // can't fail because the pty should not exist at this point. + let _ = self.resize( + GridSize { + columns: term.cols, + rows: term.rows, + }, + ScreenSize { + width: term.width_px, + height: term.height_px, + }, + ); + } + + /// Start the subprocess and the read/write threads. `output` is where + /// the read thread delivers pty output. + pub fn thread_enter( + &mut self, + output: Arc, + surface_mailbox: Arc, + _renderer_state: SharedState, + ) -> Result { + // Start our subprocess + let (read_handle, write_handle) = self.subprocess.start()?; + + // Watcher to detect subprocess exit: a duplicate of the process + // handle that the IO thread waits on. + let process = self + .subprocess + .command + .as_ref() + .and_then(Command::process_handle) + .ok_or(Error::ProcessNotStarted)? + .try_clone_to_owned()?; + + // Track our process start time for abnormal exits + let start = Instant::now(); + + // Create our pipe that we'll use to kill our read thread. + // pipe.0 is the read end, pipe.1 is the write end. + let (quit_read, quit_write) = ghostty_os::pipe::pipe()?; + + // Handles we use to interrupt the blocking I/O of the threads. + let read_cancel = read_handle.try_clone()?; + let write_cancel = write_handle.try_clone()?; + + // Start our read thread + let crash_dims = ghostty_crash::current_dims(); + let read_thread = + std::thread::Builder::new() + .name("io-reader".into()) + .spawn(move || { + let _crash = + ghostty_crash::ThreadGuard::new(ghostty_crash::ThreadType::Io, crash_dims); + read_thread_main(&read_handle, &output, &quit_read); + })?; + + // Start our write thread + let (write_tx, write_rx) = crossbeam_channel::unbounded(); + let write_thread = std::thread::Builder::new() + .name("io-writer".into()) + .spawn(move || write_thread_main(&write_handle, &write_rx))?; + + Ok(ThreadData { + start, + exited: false, + process: Some(process), + write_tx: Some(write_tx), + write_thread: Some(write_thread), + write_cancel, + read_thread: Some(read_thread), + read_thread_pipe: Some(quit_write), + read_cancel, + surface_mailbox, + }) + } + + pub fn thread_exit(&mut self, td: &mut ThreadData) { + if td.exited { + self.subprocess.external_exit(); + } + self.subprocess.stop(); + td.stop_threads(); + } + + /// The termios timer isn't supported on Windows (as in Zig), so focus + /// changes need no handling here. + #[allow(clippy::unused_self)] + pub fn focus_gained(&mut self, _td: &mut ThreadData, _focused: bool) {} + + pub fn resize(&mut self, grid_size: GridSize, screen_size: ScreenSize) -> Result<(), Error> { + self.subprocess.resize(grid_size, screen_size) + } + + #[allow(clippy::unused_self)] + pub fn queue_write(&mut self, td: &mut ThreadData, data: &[u8], linefeed: bool) { + td.queue_write(data, linefeed); + } +} + +/// The thread local data for the exec implementation. +pub struct ThreadData { + /// Process start time and boolean of whether its already exited. + start: Instant, + pub exited: bool, + + /// Duplicate of the process handle; signaled when the process exits. + process: Option, + + /// Pending writes to the pty, drained by the writer thread. + write_tx: Option>>, + write_thread: Option>, + write_cancel: OwnedHandle, + + /// Reader thread state + read_thread: Option>, + read_thread_pipe: Option, + read_cancel: OwnedHandle, + + surface_mailbox: Arc, +} + +impl ThreadData { + /// A handle that becomes signaled when the child exited. + pub fn process_handle(&self) -> Option> { + use std::os::windows::io::AsHandle; + self.process.as_ref().map(AsHandle::as_handle) + } + + /// Queue a write directly to the pty. If the process exited no more + /// writes are sent. With `linefeed` every `\r` is replaced by `\r\n`. + pub fn queue_write(&mut self, data: &[u8], linefeed: bool) { + // If our process is exited then we don't send any more writes. + if self.exited || data.is_empty() { + return; + } + let Some(tx) = &self.write_tx else { + return; + }; + + let chunk = if linefeed { + let mut buf = Vec::with_capacity(data.len() + 8); + for &ch in data { + buf.push(ch); + if ch == b'\r' { + buf.push(b'\n'); + } + } + buf + } else { + data.to_vec() + }; + let _ = tx.send(chunk); + } + + fn process_exit_common(&mut self, exit_code: u32) { + self.exited = true; + self.process = None; + // Nothing can be written anymore. + self.write_tx = None; + + // Determine how long the process was running for. + let runtime_ms = u64::try_from(self.start.elapsed().as_millis()).unwrap_or(u64::MAX); + log::debug!("child process exited status={exit_code} runtime={runtime_ms}ms"); + + // We always notify the surface immediately that the child has + // exited and some metadata about the exit. + self.surface_mailbox.push( + SurfaceMessage::ChildExited(ChildExited { + exit_code, + runtime_ms, + }), + Timeout::Forever, + ); + } + + /// Called when `process_handle()` is signaled: report the exit. + #[allow(unsafe_code)] + pub fn on_process_ready(&mut self) { + let Some(process) = &self.process else { + return; + }; + let mut code: u32 = 0; + // SAFETY: valid process handle and out pointer. + let ok = unsafe { + windows_sys::Win32::System::Threading::GetExitCodeProcess( + process.as_raw_handle() as HANDLE, + &mut code, + ) + }; + if ok == 0 { + log::warn!( + "GetExitCodeProcess failed on exit notification err={}", + io::Error::last_os_error() + ); + code = 0; + } + self.process_exit_common(code); + } + + /// The next time a timer needs to run (none on Windows). + #[allow(clippy::unused_self)] + pub fn next_deadline(&self) -> Option { + None + } + + /// Run due timers (none on Windows). + #[allow(clippy::unused_self)] + pub fn on_timer(&mut self, _now: Instant) {} + + /// Quit the read and write threads (the tail of `threadExit`). + #[allow(unsafe_code)] + fn stop_threads(&mut self) { + // Stop the writer: closing the queue ends its loop and cancelling + // wakes it if it is blocked in a write. + self.write_tx = None; + + // Quit our read thread after exiting the subprocess so that + // we don't get stuck waiting for data to stop flowing if it is + // a particularly noisy process. + if let Some(pipe) = self.read_thread_pipe.take() { + let mut written = 0u32; + // SAFETY: valid pipe handle; the buffer is valid for 1 byte. + let ok = unsafe { + WriteFile( + pipe.as_raw_handle() as HANDLE, + b"x".as_ptr(), + 1, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() }; + // A broken pipe means that our read thread is closed + // already, which is completely fine. + if err != ERROR_BROKEN_PIPE && err != ERROR_NO_DATA { + log::warn!("error writing to read thread quit pipe err={err}"); + } + } + } + + // Interrupt the blocking read/write so the threads can see the quit + // message. A cancel issued while a thread is between I/O calls is + // lost, so repeat until the threads are really gone. + for (thread, handle) in [ + (self.read_thread.take(), &self.read_cancel), + (self.write_thread.take(), &self.write_cancel), + ] { + let Some(thread) = thread else { continue }; + while !thread.is_finished() { + // SAFETY: valid handle; a null OVERLAPPED cancels all of this + // process' pending I/O on it. + if unsafe { CancelIoEx(handle.as_raw_handle() as HANDLE, std::ptr::null()) } == 0 { + let err = unsafe { GetLastError() }; + // ERROR_NOT_FOUND: nothing pending right now. + if err != windows_sys::Win32::Foundation::ERROR_NOT_FOUND { + log::warn!("error interrupting io thread err={err}"); + } + } + std::thread::sleep(Duration::from_millis(5)); + } + if thread.join().is_err() { + log::warn!("io thread panicked"); + } + } + } +} + +impl Drop for ThreadData { + fn drop(&mut self) { + // Join the threads if thread_exit wasn't called (shouldn't happen). + self.stop_threads(); + } +} + +/// Convert the way a child exited to the `u32` exit code reported to the +/// surface. +pub fn exit_code(exit: Exit) -> u32 { + match exit { + Exit::Exited(c) => c, + } +} + +/// The read thread sits in a loop doing the following pseudo code: +/// +/// while (true) { blocking_read(); exit_if_eof(); process(); } +/// +/// (`ReadThread.threadMainWindows`.) The blocking `ReadFile` is interrupted +/// with `CancelIoEx` from [`Exec::thread_exit`], after which the quit pipe is +/// checked. +#[allow(unsafe_code)] +pub fn read_thread_main(fd: &OwnedHandle, output: &Output, quit: &OwnedHandle) { + let fd_raw = fd.as_raw_handle() as HANDLE; + let quit_raw = quit.as_raw_handle() as HANDLE; + let mut buf = [0u8; 1024]; + loop { + loop { + let mut n: u32 = 0; + // SAFETY: valid handle; the buffer is valid for `buf.len()` bytes. + let ok = unsafe { + ReadFile( + fd_raw, + buf.as_mut_ptr(), + buf.len() as u32, + &mut n, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + // SAFETY: no preconditions. + let err = unsafe { GetLastError() }; + match err { + // Check for a quit signal + ERROR_OPERATION_ABORTED => break, + + // The pseudo console closed our pipe: we're done. + ERROR_BROKEN_PIPE | ERROR_PIPE_NOT_CONNECTED => { + log::info!("io reader exiting"); + return; + } + + _ => { + log::error!("io reader error err={err}"); + return; + } + } + } + + output.process_output(&buf[..n as usize]); + } + + let mut quit_bytes: u32 = 0; + // SAFETY: valid pipe handle and out pointer. + if unsafe { + PeekNamedPipe( + quit_raw, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut quit_bytes, + std::ptr::null_mut(), + ) + } == 0 + { + // SAFETY: no preconditions. + log::error!("quit pipe reader error err={}", unsafe { GetLastError() }); + return; + } + + if quit_bytes > 0 { + log::info!("read thread got quit signal"); + return; + } + } +} + +/// Drains the write queue with blocking overlapped writes to the input pipe. +#[allow(unsafe_code)] +fn write_thread_main(handle: &OwnedHandle, rx: &Receiver>) { + let raw = handle.as_raw_handle() as HANDLE; + + // Manual-reset event for the OVERLAPPED structure. + // SAFETY: default security attributes, unnamed. + let event = unsafe { CreateEventW(std::ptr::null(), TRUE, FALSE, std::ptr::null()) }; + if event.is_null() { + log::error!( + "io writer failed to create event err={}", + io::Error::last_os_error() + ); + return; + } + // SAFETY: just created and owned by us. + let event = unsafe { std::os::windows::io::FromRawHandle::from_raw_handle(event.cast()) }; + let event: OwnedHandle = event; + + while let Ok(chunk) = rx.recv() { + let mut offset = 0; + while offset < chunk.len() { + // SAFETY: OVERLAPPED is plain data. + let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() }; + ov.hEvent = event.as_raw_handle() as HANDLE; + let rest = &chunk[offset..]; + // SAFETY: valid handle; the buffer and `ov` outlive the I/O, + // which we wait for below before leaving this scope. + let ok = unsafe { + WriteFile( + raw, + rest.as_ptr(), + rest.len() as u32, + std::ptr::null_mut(), + &mut ov, + ) + }; + // SAFETY: no preconditions. + if ok == 0 && unsafe { GetLastError() } != ERROR_IO_PENDING { + log::error!("write error: {}", io::Error::last_os_error()); + break; + } + + let mut written: u32 = 0; + // SAFETY: waits for the operation started above to finish. + if unsafe { GetOverlappedResult(raw, &ov, &mut written, TRUE) } == 0 { + // SAFETY: no preconditions. + let err = unsafe { GetLastError() }; + if err == ERROR_OPERATION_ABORTED { + // We're shutting down. + return; + } + log::error!("write error: {}", io::Error::from_raw_os_error(err as i32)); + break; + } + offset += written as usize; + } + } +} + +//------------------------------------------------------------------------- +// Subprocess + +struct Subprocess { + cwd: Option, + env: Option, + args: Vec, + grid_size: GridSize, + screen_size: ScreenSize, + pty: Option, + command: Option, +} + +impl Subprocess { + /// Initialize the subprocess. This will NOT start it, this only sets + /// up the internal state necessary to start it later. + fn new(cfg: Config) -> Result { + // Get our env. If a default env isn't provided by the caller + // then we get it ourselves. + let mut env = cfg.env; + + // If we have a resources dir then set our env var + if let Some(dir) = &cfg.resources_dir { + log::info!("found Ghostty resources dir: {dir}"); + env.insert("GHOSTTY_RESOURCES_DIR", dir.clone()); + } + + // Set our TERM var. This is a bit complicated because we want to use + // the ghostty TERM value but we want to only do that if we have + // ghostty in the TERMINFO database. + if let Some(base) = &cfg.resources_dir { + env.insert("TERM", cfg.term.clone()); + env.insert("COLORTERM", "truecolor"); + + // Assume that the resources directory is adjacent to the terminfo + // database + let parent = std::path::Path::new(base) + .parent() + .map_or_else(String::new, |p| p.to_string_lossy().into_owned()); + env.insert("TERMINFO", format!("{parent}\\terminfo")); + } else { + env.insert("TERM", "xterm-256color"); + env.insert("COLORTERM", "truecolor"); + } + + // Add our binary to the path if we can find it. + add_ghostty_path(&mut env); + + // Set environment variables used by some programs (such as neovim) to detect + // which terminal emulator and version they're running under. + env.insert("TERM_PROGRAM", "ghostty"); + env.insert("TERM_PROGRAM_VERSION", VERSION_STRING); + + // VTE_VERSION is set by gnome-terminal and other VTE-based terminals. + // We don't want our child processes to think we're running under VTE. + env.remove("VTE_VERSION"); + + // Setup our shell integration, if we can. + let shell_command: ConfigCommand = 'shell: { + let default_shell_command = cfg + .command + .clone() + .unwrap_or_else(|| ConfigCommand::Shell("cmd.exe".into())); + + // Always set up shell features (GHOSTTY_SHELL_FEATURES). These are + // used by both automatic and manual shell integrations. + shell_integration::setup_features( + &mut env, + cfg.shell_integration_features, + cfg.cursor_blink.unwrap_or(true), + ); + + let force: Option = match cfg.shell_integration { + ShellIntegration::None => { + log::info!("shell integration disabled by configuration"); + break 'shell default_shell_command; + } + ShellIntegration::Detect => None, + ShellIntegration::Bash => Some(Shell::Bash), + ShellIntegration::Elvish => Some(Shell::Elvish), + ShellIntegration::Fish => Some(Shell::Fish), + ShellIntegration::Nushell => Some(Shell::Nushell), + ShellIntegration::Zsh => Some(Shell::Zsh), + }; + + let Some(dir) = &cfg.resources_dir else { + log::warn!("no resources dir set, shell integration disabled"); + break 'shell default_shell_command; + }; + + let Some(integration) = + shell_integration::setup(dir, &default_shell_command, &mut env, force)? + else { + log::warn!( + "shell could not be detected, no automatic shell integration will be injected" + ); + break 'shell default_shell_command; + }; + + log::info!( + "shell integration automatically injected shell={}", + integration.shell.tag_name() + ); + + integration.command + }; + + // Add the environment variables that override any others. + for (k, v) in cfg.env_override.iter() { + env.insert(k, v); + } + + // Build our args list. If we fail to find the shell, we'd still like + // to try to run something so Ghostty can launch. + let args = match exec_command(&shell_command, std::env::var("WINDIR").ok().as_deref()) { + Ok(args) => args, + Err(Error::SystemError) => { + log::warn!("failed to get WINDIR, cannot run shell command"); + return Err(Error::SystemError); + } + Err(err) => return Err(err), + }; + + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + let cwd = cfg.working_directory; + + // Propagate the current working directory (CWD) to the shell. + if let Some(pwd) = &cwd { + env.insert("PWD", pwd.clone()); + } + + Ok(Subprocess { + env: Some(env), + cwd, + args, + + // Should be initialized with init_terminal call. + grid_size: GridSize::default(), + screen_size: ScreenSize { + width: 1, + height: 1, + }, + pty: None, + command: None, + }) + } + + /// Start the subprocess. If the subprocess is already started this + /// will panic. Returns `(read, write)` handles: duplicates of the output + /// pipe (read end) and input pipe (write end). + fn start(&mut self) -> Result<(OwnedHandle, OwnedHandle), Error> { + assert!(self.pty.is_none() && self.command.is_none()); + + // Create our pty + let pty = Pty::open(Winsize { + ws_row: clamp_u16(u32::from(self.grid_size.rows)), + ws_col: clamp_u16(u32::from(self.grid_size.columns)), + ws_xpixel: clamp_u16(self.screen_size.width), + ws_ypixel: clamp_u16(self.screen_size.height), + })?; + let handles = ( + pty.out_pipe().try_clone_to_owned()?, + pty.in_pipe().try_clone_to_owned()?, + ); + let pseudo_console = pty.pseudo_console(); + self.pty = Some(pty); + + match self.start_process(pseudo_console) { + Ok(()) => { + // Successful start we can clear out some memory. + self.env = None; + Ok(handles) + } + Err(err) => { + // Dropping the pty closes everything. + self.pty = None; + Err(err) + } + } + } + + fn start_process( + &mut self, + pseudo_console: windows_sys::Win32::System::Console::HPCON, + ) -> Result<(), Error> { + log::debug!( + "starting command command={}", + self.args + .iter() + .map(|a| format!("`{a}`")) + .collect::>() + .join(", ") + ); + + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + let cwd: Option = match &self.cwd { + None => None, + Some(proposed) => match std::fs::metadata(proposed) { + Ok(_) => Some(proposed.clone()), + Err(err) => { + log::warn!("cannot access cwd, ignoring: {err}"); + None + } + }, + }; + + // Build our subcommand + let mut cmd = Command::default(); + cmd.path = self.args.first().cloned().ok_or(Error::ProcessNotStarted)?; + cmd.args.clone_from(&self.args); + cmd.env.clone_from(&self.env); + cmd.cwd = cwd; + cmd.pseudo_console = Some(pseudo_console); + cmd.start()?; + log::info!("started subcommand path={} pid={:?}", self.args[0], cmd.pid); + + self.command = Some(cmd); + Ok(()) + } + + /// Called to notify that we exited externally so we can unset our + /// running state. + fn external_exit(&mut self) { + self.command = None; + } + + /// Stop the subprocess. This is safe to call anytime. This does not + /// close the pty. + fn stop(&mut self) { + if let Some(cmd) = self.command.take() + && let Err(err) = kill_command(&cmd) + { + log::error!("error terminating command, may hang: {err}"); + } + } + + /// Resize the pty subprocess. This is safe to call anytime. + fn resize(&mut self, grid_size: GridSize, screen_size: ScreenSize) -> Result<(), Error> { + self.grid_size = grid_size; + self.screen_size = screen_size; + + if let Some(pty) = &mut self.pty { + // It is theoretically possible for the grid or screen size to + // exceed u16, although the terminal in that case isn't very + // usable. This should be protected upstream but we still clamp + // in case there is a bad caller which has happened before. + pty.set_size(Winsize { + ws_row: clamp_u16(u32::from(grid_size.rows)), + ws_col: clamp_u16(u32::from(grid_size.columns)), + ws_xpixel: clamp_u16(screen_size.width), + ws_ypixel: clamp_u16(screen_size.height), + })?; + } + Ok(()) + } +} + +fn clamp_u16(v: u32) -> u16 { + u16::try_from(v).unwrap_or(u16::MAX) +} + +/// Add the directory of our own binary to `PATH` and set `GHOSTTY_BIN_DIR`. +fn add_ghostty_path(env: &mut EnvMap) { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(err) => { + log::warn!("failed to get ghostty exe path err={err}"); + return; + } + }; + let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else { + return; + }; + log::debug!("appending ghostty bin to path dir={exe_dir}"); + + // We always set this so that if the shell overwrites the path + // scripts still have a way to find the Ghostty binary when + // running in Ghostty. + env.insert("GHOSTTY_BIN_DIR", exe_dir.clone()); + + // Append if we have a path. We want to append so that ghostty is + // the last priority in the path. If we don't have a path set + // then we just set it to the directory of the binary. + if let Some(path) = env.get("PATH").map(std::ffi::OsStr::to_owned) { + // Verify that our path doesn't already contain this entry + if std::env::split_paths(&path) + .any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_str()) + { + return; + } + env.insert( + "PATH", + ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)), + ); + } else { + env.insert("PATH", exe_dir); + } +} + +/// Terminate the underlying subprocess and wait for it to exit +/// (`killCommand`: `TerminateProcess(pid, 0)` then wait). +fn kill_command(command: &Command) -> Result<(), Error> { + if command.pid.is_some() { + command.terminate()?; + command.wait(false)?; + } + Ok(()) +} + +/// Builds the argv array for the process we should exec for the +/// configured command (`execCommand`). `windir` is `%WINDIR%`: shell +/// commands run as `%WINDIR%\System32\cmd.exe /C `. +pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result, Error> { + match command { + // We need to clone the command since there's no guarantee the config remains valid. + ConfigCommand::Direct(args) => Ok(args.clone()), + + ConfigCommand::Shell(v) => { + let windir = windir.ok_or(Error::SystemError)?; + Ok(windows_shell_args(windir, v)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exec_command_shell_command() { + let result = exec_command( + &ConfigCommand::Shell("foo bar baz".into()), + Some("C:\\Windows"), + ) + .unwrap(); + assert_eq!( + result, + vec!["C:\\Windows\\System32\\cmd.exe", "/C", "foo bar baz"] + ); + } + + #[test] + fn exec_command_requires_windir() { + assert!(matches!( + exec_command(&ConfigCommand::Shell("x".into()), None), + Err(Error::SystemError) + )); + } + + #[test] + fn exec_command_direct_command() { + let result = exec_command( + &ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()]), + None, + ) + .unwrap(); + assert_eq!(result, vec!["foo", "bar baz"]); + } + + #[test] + fn exit_code_mapping() { + assert_eq!(exit_code(Exit::Exited(5)), 5); + } +} diff --git a/crates/ghostty-termio/src/pty_windows.rs b/crates/ghostty-termio/src/pty_windows.rs new file mode 100644 index 0000000..dd3e745 --- /dev/null +++ b/crates/ghostty-termio/src/pty_windows.rs @@ -0,0 +1,348 @@ +//! Windows PTY creation and management (`WindowsPty` in `pty.zig`): a ConPTY +//! pseudo console (`CreatePseudoConsole`) connected to a pair of pipes. +//! +//! The Zig struct has `in_pipe`/`out_pipe` (our ends) and +//! `in_pipe_pty`/`out_pipe_pty` (the ends handed to the pseudo console). All +//! handles are `OwnedHandle`s here and are closed on drop, in the same order +//! as `WindowsPty.deinit` (pipes first, the pseudo console last). + +// Win32 FFI takes out-parameters as `*mut`; implicit `&mut` -> pointer coercions +// are the idiomatic spelling at these call sites. +#![allow(clippy::borrow_as_ptr)] + +use std::{ + io, + os::windows::io::{AsHandle, BorrowedHandle, FromRawHandle, OwnedHandle, RawHandle}, + ptr, + sync::atomic::{AtomicU32, Ordering}, +}; + +use windows_sys::Win32::{ + Foundation::{ + FALSE, + GENERIC_READ, + HANDLE, + HANDLE_FLAG_INHERIT, + INVALID_HANDLE_VALUE, + S_OK, + SetHandleInformation, + }, + Security::SECURITY_ATTRIBUTES, + Storage::FileSystem::{ + CreateFileW, + FILE_ATTRIBUTE_NORMAL, + FILE_FLAG_FIRST_PIPE_INSTANCE, + FILE_FLAG_OVERLAPPED, + OPEN_EXISTING, + PIPE_ACCESS_OUTBOUND, + }, + System::{ + Console::{COORD, ClosePseudoConsole, CreatePseudoConsole, HPCON, ResizePseudoConsole}, + Pipes::{CreateNamedPipeW, CreatePipe, PIPE_TYPE_BYTE}, + Threading::GetCurrentProcessId, + }, +}; + +/// Redeclare the winsize struct so we can just use a Rust struct. The +/// defaults on this are some reasonable screen size but you should probably +/// not use them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Winsize { + pub ws_row: u16, + pub ws_col: u16, + pub ws_xpixel: u16, + pub ws_ypixel: u16, +} + +impl Default for Winsize { + fn default() -> Self { + Self { + ws_row: 100, + ws_col: 80, + ws_xpixel: 800, + ws_ypixel: 600, + } + } +} + +/// The modes of a pty. Not all of these modes are supported on +/// all platforms but all platforms share the same mode struct. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Mode { + /// ICANON on POSIX + pub canonical: bool, + + /// ECHO on POSIX + pub echo: bool, +} + +impl Default for Mode { + fn default() -> Self { + Self { + canonical: true, + echo: true, + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + /// A Win32 call failed (`windows.unexpectedError`); carries the + /// `GetLastError` code, or the failing `HRESULT` for ConPTY calls. + #[error("unexpected windows error {0:#x}")] + Unexpected(u32), + #[error("resize failed")] + ResizeFailed, +} + +/// Process-wide counter for pipe names. +static PIPE_NAME_COUNTER: AtomicU32 = AtomicU32::new(1); + +/// The pseudo console handle; closed (after the pipes) on drop. +#[derive(Debug)] +struct PseudoConsole(HPCON); + +impl Drop for PseudoConsole { + #[allow(unsafe_code)] + fn drop(&mut self) { + // SAFETY: the handle came from a successful CreatePseudoConsole and + // is closed exactly once, here. + unsafe { ClosePseudoConsole(self.0) }; + } +} + +/// A ConPTY pseudo console with its pipes. +/// +/// Field order is the drop order: pipes first, the pseudo console last. +#[derive(Debug)] +#[allow(clippy::struct_field_names)] +pub struct Pty { + // The pseudo console's ends of the pipes are only held to keep them open. + #[allow(dead_code)] + in_pipe_pty: OwnedHandle, + /// Our write end (named pipe, overlapped) for input to the child. + in_pipe: OwnedHandle, + #[allow(dead_code)] + out_pipe_pty: OwnedHandle, + /// Our read end for output from the child. + out_pipe: OwnedHandle, + pseudo_console: PseudoConsole, + size: Winsize, +} + +fn last_error() -> Error { + Error::Unexpected(io::Error::last_os_error().raw_os_error().unwrap_or(0) as u32) +} + +/// The unique named pipe path for the input pipe, as UTF-16 with a NUL. +fn pipe_path(pid: u32, counter: u32) -> Vec { + format!("\\\\.\\pipe\\LOCAL\\ghostty-pty-{pid}-{counter}\0") + .encode_utf16() + .collect() +} + +fn coord(size: Winsize) -> COORD { + COORD { + X: size.ws_col.min(i16::MAX as u16) as i16, + Y: size.ws_row.min(i16::MAX as u16) as i16, + } +} + +impl Pty { + /// Open a new PTY with the given initial size. + #[allow(unsafe_code)] + pub fn open(size: Winsize) -> Result { + let pipe_path_w = pipe_path( + // SAFETY: no preconditions. + unsafe { GetCurrentProcessId() }, + PIPE_NAME_COUNTER.fetch_add(1, Ordering::Relaxed), + ); + + let security_attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + bInheritHandle: FALSE, + lpSecurityDescriptor: ptr::null_mut(), + }; + + // The in_pipe needs to be created as a named pipe, since anonymous + // pipes created with CreatePipe do not support overlapped operations + // (which Zig's libxev IOCP backend needs; we keep the same shape). + // + // SAFETY: the path is NUL terminated and the attributes outlive the + // call. + let in_pipe = unsafe { + CreateNamedPipeW( + pipe_path_w.as_ptr(), + PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE, + 1, + 4096, + 4096, + 0, + &security_attributes, + ) + }; + if in_pipe == INVALID_HANDLE_VALUE { + return Err(last_error()); + } + // SAFETY: valid handle we own. + let in_pipe = unsafe { OwnedHandle::from_raw_handle(in_pipe as RawHandle) }; + + // SAFETY: as above. + let in_pipe_pty = unsafe { + CreateFileW( + pipe_path_w.as_ptr(), + GENERIC_READ, + 0, + &security_attributes, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + ptr::null_mut(), + ) + }; + if in_pipe_pty == INVALID_HANDLE_VALUE { + return Err(last_error()); + } + // SAFETY: valid handle we own. + let in_pipe_pty = unsafe { OwnedHandle::from_raw_handle(in_pipe_pty as RawHandle) }; + + let mut out_pipe: HANDLE = ptr::null_mut(); + let mut out_pipe_pty: HANDLE = ptr::null_mut(); + // SAFETY: both out pointers are valid for writes. + if unsafe { CreatePipe(&mut out_pipe, &mut out_pipe_pty, ptr::null(), 0) } == 0 { + return Err(last_error()); + } + // SAFETY: CreatePipe succeeded: two valid handles we own. + let (out_pipe, out_pipe_pty) = unsafe { + ( + OwnedHandle::from_raw_handle(out_pipe as RawHandle), + OwnedHandle::from_raw_handle(out_pipe_pty as RawHandle), + ) + }; + + for h in [&in_pipe, &in_pipe_pty, &out_pipe, &out_pipe_pty] { + set_not_inheritable(h.as_handle())?; + } + + let mut hpc: HPCON = 0; + // SAFETY: the pipe handles are valid; `hpc` is a valid out pointer. + let result = unsafe { + CreatePseudoConsole( + coord(size), + raw(&in_pipe_pty), + raw(&out_pipe_pty), + 0, + &mut hpc, + ) + }; + if result != S_OK { + return Err(Error::Unexpected(result as u32)); + } + + Ok(Pty { + in_pipe_pty, + in_pipe, + out_pipe_pty, + out_pipe, + pseudo_console: PseudoConsole(hpc), + size, + }) + } + + /// Return the size of the pty. + pub fn get_size(&self) -> Result { + Ok(self.size) + } + + /// Set the size of the pty. + #[allow(unsafe_code)] + pub fn set_size(&mut self, size: Winsize) -> Result<(), Error> { + // SAFETY: the pseudo console handle is valid until drop. + let result = unsafe { ResizePseudoConsole(self.pseudo_console.0, coord(size)) }; + if result != S_OK { + return Err(Error::ResizeFailed); + } + self.size = size; + Ok(()) + } + + /// The pseudo console to attach a child process to (`Command.pseudo_console`). + pub fn pseudo_console(&self) -> HPCON { + self.pseudo_console.0 + } + + /// Our read end: output of the child. + pub fn out_pipe(&self) -> BorrowedHandle<'_> { + self.out_pipe.as_handle() + } + + /// Our write end: input to the child (opened for overlapped I/O). + pub fn in_pipe(&self) -> BorrowedHandle<'_> { + self.in_pipe.as_handle() + } +} + +fn raw(h: &OwnedHandle) -> HANDLE { + std::os::windows::io::AsRawHandle::as_raw_handle(h) as HANDLE +} + +#[allow(unsafe_code)] +fn set_not_inheritable(h: BorrowedHandle<'_>) -> Result<(), Error> { + // SAFETY: the handle is valid for the duration of the call. + if unsafe { + SetHandleInformation( + std::os::windows::io::AsRawHandle::as_raw_handle(&h) as HANDLE, + HANDLE_FLAG_INHERIT, + 0, + ) + } == 0 + { + return Err(last_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pty_size() { + let mut ws = Winsize { + ws_row: 50, + ws_col: 80, + ws_xpixel: 1, + ws_ypixel: 1, + }; + + let mut pty = Pty::open(ws).unwrap(); + + // Initialize size should match what we gave it + assert_eq!(ws, pty.get_size().unwrap()); + + // Can set and read new sizes + ws.ws_row *= 2; + pty.set_size(ws).unwrap(); + assert_eq!(ws, pty.get_size().unwrap()); + } + + #[test] + fn pipe_names_are_unique_and_nul_terminated() { + let a = pipe_path(7, 1); + let b = pipe_path(7, 2); + assert_ne!(a, b); + assert_eq!(a.last(), Some(&0)); + assert!(String::from_utf16_lossy(&a).starts_with("\\\\.\\pipe\\LOCAL\\ghostty-pty-7-1")); + } + + #[test] + fn coord_clamps() { + let c = coord(Winsize { + ws_row: 50, + ws_col: u16::MAX, + ws_xpixel: 0, + ws_ypixel: 0, + }); + assert_eq!((c.X, c.Y), (i16::MAX, 50)); + } +} diff --git a/crates/ghostty-termio/src/thread_windows.rs b/crates/ghostty-termio/src/thread_windows.rs new file mode 100644 index 0000000..c3f2924 --- /dev/null +++ b/crates/ghostty-termio/src/thread_windows.rs @@ -0,0 +1,151 @@ +//! The Windows event loop of the termio thread (see [`super`]). libxev's IOCP +//! loop is replaced by a single `WaitForMultipleObjects` over: the mailbox +//! wakeup event, the stop event, the child's process handle and a timeout +//! for the deadlines (selection scroll, resize coalescing, synchronized +//! output reset). Writes to the pty are done by the exec backend's writer +//! thread, so there is no write readiness to wait for. + +use std::{os::windows::io::AsRawHandle, time::Instant}; + +use windows_sys::Win32::{ + Foundation::{HANDLE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{INFINITE, WaitForMultipleObjects, WaitForSingleObject}, +}; + +use super::Thread; +use crate::termio::{Termio, ThreadData}; + +/// The maximum number of milliseconds for one wait (must stay below +/// `INFINITE`). +const MAX_WAIT_MS: u128 = 0xFFFF_FFF0; + +/// Milliseconds until `deadline`, rounded up so we never wake early and spin. +fn timeout_ms(deadline: Option, now: Instant) -> u32 { + match deadline { + None => INFINITE, + Some(d) => { + let dur = d.saturating_duration_since(now); + let ms = dur.as_millis() + u128::from(dur.subsec_nanos() % 1_000_000 != 0); + ms.min(MAX_WAIT_MS) as u32 + } + } +} + +/// True if the (manual- or auto-reset) object is signaled right now. +#[allow(unsafe_code)] +fn is_signaled(h: HANDLE) -> bool { + // SAFETY: valid handle owned by the caller for the duration of the call. + unsafe { WaitForSingleObject(h, 0) == WAIT_OBJECT_0 } +} + +impl Thread { + /// After an abnormal failure: discard messages until the stop signal. + #[allow(unsafe_code)] + pub(super) fn drain_until_stop(&mut self, io: &Termio) { + let mailbox = io.mailbox().clone(); + loop { + let handles = [ + mailbox.wakeup().as_handle().as_raw_handle() as HANDLE, + self.stop.as_handle().as_raw_handle() as HANDLE, + ]; + // SAFETY: `handles` is a valid array of valid handles. + let rc = unsafe { + WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, INFINITE) + }; + if rc == WAIT_FAILED { + log::error!( + "failed to wait for draining err={}", + std::io::Error::last_os_error() + ); + return; + } + // The auto-reset events were consumed by the wait (the one that + // fired) or by `drain`; check both explicitly like the poll loop. + let stop = rc == WAIT_OBJECT_0 + 1 || self.stop.drain(); + let msgs = rc == WAIT_OBJECT_0 || mailbox.wakeup().drain(); + if msgs { + while mailbox.pop().is_some() {} + } + if stop { + return; + } + } + } + + /// The event loop. Returns when stopped. + #[allow(unsafe_code)] + pub(super) fn run(&mut self, io: &mut Termio, td: &mut ThreadData) { + let mailbox = io.mailbox().clone(); + + loop { + let timeout = timeout_ms(self.next_deadline(td), Instant::now()); + + // Build the wait set: fixed entries first, optional ones after. + let mut handles: Vec = Vec::with_capacity(3); + handles.push(mailbox.wakeup().as_handle().as_raw_handle() as HANDLE); + handles.push(self.stop.as_handle().as_raw_handle() as HANDLE); + let proc_idx = td.backend.as_exec().process_handle().map(|h| { + handles.push(h.as_raw_handle() as HANDLE); + handles.len() - 1 + }); + + // SAFETY: `handles` is a valid array of valid handles that stay + // alive (owned by `mailbox`, `self` and `td`) during the wait. + let rc = unsafe { + WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, timeout) + }; + if rc == WAIT_FAILED { + log::error!( + "io thread wait failed err={}", + std::io::Error::last_os_error() + ); + return; + } + + // WaitForMultipleObjects reports only the lowest signaled index + // (and consumes it if it is an auto-reset event), so probe the + // others without waiting. A timeout means nothing is signaled. + let signaled = |i: usize| -> bool { + let hit = (rc - WAIT_OBJECT_0) as usize == i; + hit || (rc != WAIT_TIMEOUT && is_signaled(handles[i])) + }; + + // Stop requested. + if signaled(1) { + self.stop.drain(); + return; + } + + // The mailbox has messages. + if signaled(0) { + // When we wake up, we check the mailbox. Mailbox producers + // should wake up our thread after publishing. + mailbox.wakeup().drain(); + self.drain_mailbox(io, td); + } + + if proc_idx.is_some_and(signaled) { + td.backend.as_exec_mut().on_process_ready(); + } + + self.run_timers(io, td, Instant::now()); + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn timeout_rounds_up_and_clamps() { + let now = Instant::now(); + assert_eq!(timeout_ms(None, now), INFINITE); + assert_eq!(timeout_ms(Some(now), now), 0); + assert_eq!(timeout_ms(Some(now + Duration::from_millis(25)), now), 25); + assert_eq!(timeout_ms(Some(now + Duration::from_micros(1500)), now), 2); + assert!(timeout_ms(Some(now + Duration::from_secs(10_000_000)), now) < INFINITE); + } +} diff --git a/crates/ghostty-termio/src/wakeup_windows.rs b/crates/ghostty-termio/src/wakeup_windows.rs new file mode 100644 index 0000000..e3a0501 --- /dev/null +++ b/crates/ghostty-termio/src/wakeup_windows.rs @@ -0,0 +1,122 @@ +//! A tiny cross-thread wakeup primitive (replaces `xev.Async`), Windows +//! variant of [`crate::wakeup`]. +//! +//! Backed by an auto-reset event object, so it can be included in a +//! `WaitForMultipleObjects` set next to a process handle. +//! [`Wakeup::notify`] may be called from any thread, any number of times +//! (notifications coalesce); the waiter calls [`Wakeup::drain`] after the +//! wait reports the event signaled. + +// Win32 FFI takes out-parameters as `*mut`; implicit `&mut` -> pointer coercions +// are the idiomatic spelling at these call sites. +#![allow(clippy::borrow_as_ptr)] + +use std::{ + io, + os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}, + ptr, + sync::Arc, +}; + +use windows_sys::Win32::{ + Foundation::{HANDLE, WAIT_OBJECT_0}, + System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}, +}; + +/// See the module docs. Cloning shares the same underlying event. +#[derive(Clone)] +pub struct Wakeup(Arc, Option>); + +impl std::fmt::Debug for Wakeup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Wakeup").field(&self.0).finish() + } +} + +#[allow(unsafe_code)] +fn new_event() -> io::Result { + // SAFETY: default security, auto-reset, initially non-signaled, unnamed. + let h = unsafe { CreateEventW(ptr::null(), 0, 0, ptr::null()) }; + if h.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateEventW succeeded; we own the handle. + Ok(unsafe { OwnedHandle::from_raw_handle(h.cast()) }) +} + +impl Wakeup { + pub fn new() -> io::Result { + Ok(Wakeup(Arc::new(new_event()?), None)) + } + + /// A wakeup that calls `hook` instead of signalling the event. Used to + /// bridge into waiters that are not handle-based (the renderer thread's + /// channel). `as_handle`/`drain` are meaningless for these. + pub fn with_hook(hook: Arc) -> io::Result { + Ok(Wakeup(Arc::new(new_event()?), Some(hook))) + } + + /// Wake up the waiter. Multiple notifications coalesce. + #[allow(unsafe_code)] + pub fn notify(&self) -> io::Result<()> { + if let Some(hook) = &self.1 { + hook(); + return Ok(()); + } + // SAFETY: valid event handle. + if unsafe { SetEvent(self.0.as_raw_handle() as HANDLE) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + /// Reset the notification state. Returns true if a notification was + /// pending. + #[allow(unsafe_code)] + pub fn drain(&self) -> bool { + // SAFETY: valid event handle. A zero timeout polls; success on an + // auto-reset event also resets it. + unsafe { WaitForSingleObject(self.0.as_raw_handle() as HANDLE, 0) == WAIT_OBJECT_0 } + } + + /// The handle to wait on (signaled when notified). + pub fn as_handle(&self) -> BorrowedHandle<'_> { + self.0.as_handle() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signaled(w: &Wakeup) -> bool { + // Polling consumes the (auto-reset) event, so re-arm if it fired. + let fired = w.drain(); + if fired { + w.notify().unwrap(); + } + fired + } + + #[test] + fn notify_coalesces_and_drains() { + let w = Wakeup::new().unwrap(); + assert!(!signaled(&w)); + w.notify().unwrap(); + w.notify().unwrap(); + assert!(signaled(&w)); + assert!(w.drain()); + assert!(!signaled(&w)); + assert!(!w.drain()); + } + + #[test] + fn notify_from_other_thread() { + let w = Wakeup::new().unwrap(); + let w2 = w.clone(); + std::thread::spawn(move || w2.notify().unwrap()) + .join() + .unwrap(); + assert!(signaled(&w)); + } +} diff --git a/crates/ghostty-termio/src/windows_args.rs b/crates/ghostty-termio/src/windows_args.rs new file mode 100644 index 0000000..00cc7df --- /dev/null +++ b/crates/ghostty-termio/src/windows_args.rs @@ -0,0 +1,290 @@ +//! Pure (platform independent) encoders for the Windows `CreateProcessW` +//! inputs, ported from `Command.zig` (`createWindowsEnvBlock` and +//! `windowsCreateCommandLine`, both copied from Zig's `std.process.Child`). +//! +//! They live outside the `cfg(windows)` code so they can be unit tested on +//! every host. + +use std::ffi::OsStr; + +/// Errors from encoding strings for the Windows APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum EncodeError { + /// The input is not valid Unicode (Zig: `error.InvalidUtf8`). + #[error("invalid utf-8")] + InvalidUtf8, +} + +/// Convert an OS string to UTF-16 code units. On Windows this is lossless +/// (`encode_wide`); elsewhere the string must be valid UTF-8. +pub fn os_str_to_utf16(s: &OsStr) -> Result, EncodeError> { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + Ok(s.encode_wide().collect()) + } + #[cfg(not(windows))] + { + Ok(s.to_str() + .ok_or(EncodeError::InvalidUtf8)? + .encode_utf16() + .collect()) + } +} + +/// Create a UTF-16 environment block for `CreateProcessW` with +/// `CREATE_UNICODE_ENVIRONMENT`: a sequence of `KEY=VALUE\0` entries +/// terminated by an additional `\0`. Like Zig's copy of std's function four +/// NUL code units are appended after the last entry's own terminator (an empty +/// map is just those four), and entries keep the iteration order. +pub fn create_windows_env_block<'a>( + env: impl IntoIterator, +) -> Result, EncodeError> { + let mut result: Vec = Vec::new(); + for (key, value) in env { + result.extend(os_str_to_utf16(key)?); + result.push(u16::from(b'=')); + result.extend(os_str_to_utf16(value)?); + result.push(0); + } + result.extend([0, 0, 0, 0]); + Ok(result) +} + +/// Build the `lpCommandLine` for `CreateProcessW` from an argv, quoting +/// arguments following the rules the MSVC runtime uses to split them again +/// (`CommandLineToArgvW`): arguments containing space, tab, newline or a +/// double quote are quoted; backslashes are doubled only when they precede a +/// quote (including the closing quote). +pub fn windows_create_command_line>(argv: &[S]) -> String { + let mut buf = String::new(); + for (arg_i, arg) in argv.iter().enumerate() { + let arg = arg.as_ref(); + if arg_i != 0 { + buf.push(' '); + } + if !arg.contains([' ', '\t', '\n', '"']) { + buf.push_str(arg); + continue; + } + buf.push('"'); + let mut backslash_count = 0usize; + for c in arg.chars() { + match c { + '\\' => backslash_count += 1, + '"' => { + push_n(&mut buf, '\\', backslash_count * 2 + 1); + buf.push('"'); + backslash_count = 0; + } + c => { + push_n(&mut buf, '\\', backslash_count); + buf.push(c); + backslash_count = 0; + } + } + } + push_n(&mut buf, '\\', backslash_count * 2); + buf.push('"'); + } + buf +} + +/// The argv for a `shell` command on Windows (`execCommand`): the command is +/// run as `%WINDIR%\System32\cmd.exe /C `. +pub fn windows_shell_args(windir: &str, command: &str) -> Vec { + let mut cmd = windir.to_owned(); + for part in ["System32", "cmd.exe"] { + if !cmd.is_empty() && !cmd.ends_with(['\\', '/']) { + cmd.push('\\'); + } + cmd.push_str(part); + } + vec![cmd, "/C".to_owned(), command.to_owned()] +} + +fn push_n(s: &mut String, c: char, n: usize) { + s.extend(std::iter::repeat_n(c, n)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cl(args: &[&str]) -> String { + windows_create_command_line(args) + } + + #[test] + fn shell_args_use_windir_cmd() { + assert_eq!( + windows_shell_args(r"C:\Windows", "echo hi"), + vec![r"C:\Windows\System32\cmd.exe", "/C", "echo hi"] + ); + // A trailing separator isn't doubled. + assert_eq!( + windows_shell_args(r"C:\Windows\", "x")[0], + r"C:\Windows\System32\cmd.exe" + ); + } + + #[test] + fn command_line_plain_args_are_not_quoted() { + assert_eq!(cl(&["cmd.exe"]), "cmd.exe"); + assert_eq!(cl(&["a", "b", "c"]), "a b c"); + assert_eq!(cl(&[]), ""); + // Backslashes alone never trigger quoting and are left as-is. + assert_eq!( + cl(&[r"C:\dir\file", r"\\server\share"]), + r"C:\dir\file \\server\share" + ); + } + + #[test] + fn command_line_quotes_whitespace_and_quotes() { + assert_eq!(cl(&["a b"]), r#""a b""#); + assert_eq!(cl(&["a\tb"]), "\"a\tb\""); + assert_eq!(cl(&["a\nb"]), "\"a\nb\""); + assert_eq!(cl(&[r#"a"b"#]), r#""a\"b""#); + assert_eq!(cl(&[""]), ""); + } + + #[test] + fn command_line_backslashes_before_quotes() { + // Backslashes before a quote are doubled plus one to escape the quote. + assert_eq!(cl(&[r#"a\"b"#]), r#""a\\\"b""#); + assert_eq!(cl(&[r#"a\\"b"#]), r#""a\\\\\"b""#); + // Backslashes elsewhere in a quoted argument are kept. + assert_eq!(cl(&[r"C:\Program Files\x"]), r#""C:\Program Files\x""#); + // Trailing backslashes are doubled so they don't escape the closing quote. + assert_eq!(cl(&[r"C:\Program Files\"]), r#""C:\Program Files\\""#); + assert_eq!(cl(&[r"a b\\"]), r#""a b\\\\""#); + } + + #[test] + fn command_line_mixed() { + assert_eq!( + cl(&["cmd.exe", "/c", "echo hello world", r#"say "hi""#]), + r#"cmd.exe /c "echo hello world" "say \"hi\"""# + ); + } + + /// Reference implementation of the `CommandLineToArgvW` splitting rules + /// for the subset of command lines we generate. + fn split(line: &str) -> Vec { + let chars: Vec = line.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < chars.len() { + while i < chars.len() && chars[i] == ' ' { + i += 1; + } + if i >= chars.len() { + break; + } + let mut arg = String::new(); + let mut in_quotes = false; + while i < chars.len() && (in_quotes || chars[i] != ' ') { + let mut bs = 0; + while i < chars.len() && chars[i] == '\\' { + bs += 1; + i += 1; + } + if i < chars.len() && chars[i] == '"' { + arg.extend(std::iter::repeat_n('\\', bs / 2)); + if bs % 2 == 1 { + arg.push('"'); + } else { + in_quotes = !in_quotes; + } + i += 1; + } else { + arg.extend(std::iter::repeat_n('\\', bs)); + if i < chars.len() && (in_quotes || chars[i] != ' ') { + arg.push(chars[i]); + i += 1; + } + } + } + out.push(arg); + } + out + } + + #[test] + fn command_line_round_trips() { + let cases: &[&[&str]] = &[ + &["prog", "plain"], + &["prog", "with space", "tab\there"], + &["prog", r#"quote"inside"#, r"\", r"trail\\"], + &["prog", r#"a\"b c"#, r"C:\Program Files\app\"], + &["prog", r#"""#, r#"\""#, r#"x y\\\"z"#], + ]; + for argv in cases { + let line = windows_create_command_line(argv); + let back = split(&line); + let want: Vec = argv.iter().map(|s| (*s).to_owned()).collect(); + assert_eq!(back, want, "line: {line}"); + } + } + + fn block_to_entries(block: &[u16]) -> Vec { + // Entries are NUL terminated; the block ends with extra NULs. + let mut entries = Vec::new(); + let mut cur = Vec::new(); + for &c in block { + if c == 0 { + if !cur.is_empty() { + entries.push(String::from_utf16(&cur).unwrap()); + cur.clear(); + } + } else { + cur.push(c); + } + } + entries + } + + #[test] + fn env_block_layout() { + let env = [ + (OsStr::new("A"), OsStr::new("1")), + (OsStr::new("PATH"), OsStr::new("C:\\bin;D:\\bin")), + ]; + let block = create_windows_env_block(env).unwrap(); + let expected: Vec = "A=1\0PATH=C:\\bin;D:\\bin\0\0\0\0\0" + .encode_utf16() + .collect(); + assert_eq!(block, expected); + assert_eq!( + block_to_entries(&block), + vec!["A=1".to_owned(), "PATH=C:\\bin;D:\\bin".to_owned()] + ); + } + + #[test] + fn env_block_empty_map_has_four_nuls() { + let block = create_windows_env_block(std::iter::empty()).unwrap(); + assert_eq!(block, vec![0, 0, 0, 0]); + } + + #[test] + fn env_block_encodes_non_ascii_as_utf16() { + let env = [(OsStr::new("K"), OsStr::new("héllo \u{1F600}"))]; + let block = create_windows_env_block(env).unwrap(); + let text: Vec = "K=héllo \u{1F600}\0".encode_utf16().collect(); + assert_eq!(&block[..text.len()], text.as_slice()); + // The emoji is a surrogate pair. + assert!(block.iter().any(|&c| (0xD800..0xDC00).contains(&c))); + assert_eq!(&block[text.len()..], &[0, 0, 0, 0]); + } + + #[cfg(unix)] + #[test] + fn env_block_rejects_invalid_unicode() { + use std::os::unix::ffi::OsStrExt; + let bad = OsStr::from_bytes(&[0xff, 0xfe]); + let env = [(OsStr::new("K"), bad)]; + assert_eq!(create_windows_env_block(env), Err(EncodeError::InvalidUtf8)); + } +} diff --git a/docs/rewrite/notes/windows-WIN.md b/docs/rewrite/notes/windows-WIN.md new file mode 100644 index 0000000..2f47aa9 --- /dev/null +++ b/docs/rewrite/notes/windows-WIN.md @@ -0,0 +1,105 @@ +# Windows port (agent WIN) + +Windows-specific Zig code ported behind `cfg(windows)`; Linux code paths are +unchanged. Windows is compile-checked only (`cargo check --target +x86_64-pc-windows-gnu`), nothing can be run here. + +## Verifying + +``` +RUSTC_WRAPPER= PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_PATH= \ +CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar \ +cargo check --target x86_64-pc-windows-gnu --tests \ + -p ghostty-vt -p ghostty-unicode -p ghostty-datastruct -p ghostty-input \ + -p ghostty-os -p ghostty-config -p ghostty-terminal -p ghostty-termio +``` +* `RUSTC_WRAPPER=` is needed because the `kache` rustc wrapper from + `~/.cargo/config.toml` is picked up by `cc-rs` as a compiler launcher and + breaks the mingw C builds (onig_sys, freetype-sys, harfbuzz-sys). +* mingw (`x86_64-w64-mingw32-gcc/g++`, cmake) exists, so the C crates build: + `onig_sys` natively, `freetype-sys` / `harfbuzz-sys` via their `bundled` + feature (enabled only in the `cfg(windows)` dependency table of + ghostty-font; Zig also builds them from source). +* `yeslogic-fontconfig-sys` still wants `pkg-config` for fontconfig (Zig's + default backend there is `fontconfig_freetype`, built from source). For + `cargo check` a stub `fontconfig.pc` + `PKG_CONFIG_ALLOW_CROSS=1` is enough + (no linking happens). A real Windows link needs fontconfig from vcpkg/MSYS2. +* Windows clippy: `cargo clippy --target x86_64-pc-windows-gnu --no-deps + -p ghostty-os -p ghostty-termio -p ghostty-crash -p ghostty-config + -p ghostty-terminal -p ghostty-input --all-targets -- -D warnings` is clean. + +## What was ported + +| Zig | Rust | +|-----|------| +| `os/windows.zig` | `ghostty-os/src/windows.rs` (`windows-sys` re-export, `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`, `to_wide_nul`, `computer_name`) | +| `os/pipe.zig` (CreatePipe) | `ghostty-os/src/pipe.rs` (returns `OwnedHandle`s) | +| `os/homedir.zig` | `homedir.rs`: `HOMEDRIVE`+`HOMEPATH` (`home_windows`, tested), `expand_home` is identity | +| `os/env.zig` | `;` delimiter; `EnvMap` is ASCII case-insensitive on Windows (Zig's EnvMap hash context); `filter_snap_paths` no-op | +| `os/file.zig` | `alloc_tmp_dir` = `%TMP%` only (None if unset); rlimit helpers are unix only | +| `os/path.zig` | `;` delimiter, `\` separator, every file is executable | +| `os/hostname.zig` | `GetComputerNameA` | +| `os/open.zig` | `rundll32 url.dll,FileProtocolHandler` (merged with M1's `open_command`) | +| `os/xdg.zig` | `LOCALAPPDATA` fallback for the XDG env var | +| `os/resourcesdir.zig` | sentinel `terminfo/ghostty.terminfo` | +| `os/desktop.zig` | `DesktopEnvironment::Windows`, `launched_from_desktop` uses `std::process::id` | +| `os/i18n.zig` | `init` is a no-op, `tr` returns the msgid (gettext is not built on Windows) | +| `os/temp_dir` | `BCryptGenRandom` for the name, no unix mode | +| `os/passwd.zig` (`@compileError` on Windows) | `ghostty_os::passwd` stub returning an empty `Entry`; `flatpak::is_flatpak()` is `false` | +| `pty.zig` `WindowsPty` | `ghostty-termio/src/pty_windows.rs` (named pipe for input, anonymous pipe for output, `CreatePseudoConsole`/`ResizePseudoConsole`/`ClosePseudoConsole`, non-inheritable handles, drop order as `deinit`) | +| `Command.zig` Windows branch | `command_windows.rs` (`CreateProcessW` + `STARTUPINFOEXW` + proc-thread attribute list for the pseudo console, NUL stdio, cwd, env block, `wait` = `WaitForSingleObject`+`GetExitCodeProcess`, `terminate`) and the pure `windows_args.rs` (`windows_create_command_line`, `create_windows_env_block`, `windows_shell_args`) | +| `termio/Exec.zig` Windows branches | `exec_windows.rs` (read thread = blocking `ReadFile` on the output pipe, quit pipe polled with `PeekNamedPipe`, interrupted with `CancelIoEx`; process exit via the process handle; `cmd.exe` default shell and `%WINDIR%\System32\cmd.exe /C` for shell commands; `TerminateProcess` on stop) | +| `Thread.zig` loop (libxev IOCP) | `thread_windows.rs`: `WaitForMultipleObjects` over mailbox event, stop event, process handle + deadline timeout | +| `xev.Async` | `wakeup_windows.rs`: auto-reset event | +| `config/Config.zig` Windows branches | `load_cli_args` has no argc fast path, `probable_cli_environment` is false, default shell `cmd.exe` + `%HOMEDRIVE%%HOMEPATH%` working dir, `~/` not expanded in `path.rs` | +| `input/keycodes.zig` | native column 3 (win) on Windows | +| `terminal/kitty/graphics_image.zig` | shared-memory medium returns `UnsupportedMedium`, tmp dir = `%TMP%` | +| `crash/sentry.zig` | `init` is a no-op on Windows (as Zig), `signal_windows.rs` provides only `random_bytes` | + +Module selection in termio is `#[cfg_attr(windows, path = "..._windows.rs")]` +in `lib.rs` (pty, command, exec, wakeup) so the Linux files are untouched; +`thread.rs` only gets `#[cfg(unix)]` on its poll loop and a `#[cfg(windows)] +mod windows_loop` child module. + +## Dependency gating + +* ghostty-os: `gettext-rs`, `nix`, `rustix`, `zbus` -> `cfg(not(any(wasm32, windows)))`; + `windows-sys` -> `cfg(windows)`. Unix-only modules (`flatpak`, `passwd`, + `systemd`) are `cfg(unix)`; `flatpak` / `passwd` have tiny Windows stand-ins. +* ghostty-termio: `libc`, `nix`, `rustix` (+ dev `zbus`, `rustix`) -> `cfg(unix)`; + `windows-sys` -> `cfg(windows)`. Linux integration tests got `#![cfg(unix)]`. +* ghostty-terminal: `rustix` (shm) -> not wasm/windows. +* ghostty-crash: `libc` -> `cfg(unix)`. +* ghostty-font: bundled freetype/harfbuzz on Windows (see above). + +## Tests + +Platform independent (run natively): `windows_args` (10 tests: command-line +quoting rules incl. a CommandLineToArgvW round-trip reference splitter, env +block layout/UTF-16/invalid unicode, `%WINDIR%` shell args), `path_join_with` +Windows flavour, `home_windows`. Windows-only (compiled, not runnable here): +ConPTY size, pipe round-trip, `cmd.exe /c exit 7`, wakeup event, wait timeout +rounding, `exec_command` Windows variants, `hostname.exe` PATH lookup. +Unix-specific existing tests are `cfg(unix)`-gated. + +## Deviations / gaps + +* No termios timer on Windows (Zig doesn't run it either); `focus_gained` is a + no-op (Zig would `@panic` there). +* Input writes go through a writer thread doing blocking overlapped + `WriteFile`s (no libxev). Output uses plain blocking `ReadFile`. +* Windows `exec::Config` omits `rt_pre_exec_info`, `rt_post_fork_info`, + `create_scope`, `restore_rlimit` (cgroup/rlimit are Linux-only); + ghostty-core's surface setup will need `cfg` around them when it gets a + Windows apprt. +* `Command::wait(block)` ignores `block` (blocks, like Zig). The thread + handle from `CreateProcessW` is closed (Zig leaks it). +* Fontconfig discovery is kept on Windows (needs a real fontconfig to link). +* Not touched: `cli/ssh-cache/DiskCache` file locking, `cli/boo|list_colors| + list_keybinds|edit_config` Windows branches (ghostty-cli), `benchmark/ + CodepointWidth`, `config/Config.zig` `probableCliEnvironment` env-based + extras. ghostty-core/apprt-gtk are not Windows-checkable (GTK). +* Unrelated breakage seen while verifying (other agents): `ghostty-config` + `config.rs` uses `ghostty_os::launched_from_desktop` under `cfg!(macos)` + which fails on wasm32; `ghostty-os/tests/common/flatpak_mock.rs` fails + `clippy::needless_pass_by_value`; `ghostty-font` coretext_math clippy.