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
This commit is contained in:
1 parent
84f665cdfb
commit
76771bed0d
8 files changed
+2402
No files matched your search
@@ -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<u16> {
|
||||||
|
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<String> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
|
||||||
|
/// 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<ghostty_os::EnvMap>,
|
||||||
|
|
||||||
|
/// Working directory for the child. If not set, the working directory of
|
||||||
|
/// the calling process is preserved.
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
|
||||||
|
/// 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<OwnedHandle>,
|
||||||
|
pub stdout: Option<OwnedHandle>,
|
||||||
|
pub stderr: Option<OwnedHandle>,
|
||||||
|
|
||||||
|
/// 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<HPCON>,
|
||||||
|
|
||||||
|
/// Process handle (Zig stores it in `pid`), set after start is called.
|
||||||
|
process: Option<OwnedHandle>,
|
||||||
|
|
||||||
|
/// Process ID, set after start is called.
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>, 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<BorrowedHandle<'_>> {
|
||||||
|
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<u64> = 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::<HPCON>(),
|
||||||
|
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<OwnedHandle>| -> 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::<windows_sys::Win32::System::Threading::STARTUPINFOW>() as u32
|
||||||
|
} else {
|
||||||
|
std::mem::size_of::<STARTUPINFOEXW>() 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<Exit> {
|
||||||
|
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<u32> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ConfigCommand>,
|
||||||
|
pub env: EnvMap,
|
||||||
|
pub env_override: RepeatableStringMap,
|
||||||
|
pub shell_integration: ShellIntegration,
|
||||||
|
pub shell_integration_features: ShellIntegrationFeatures,
|
||||||
|
pub cursor_blink: Option<bool>,
|
||||||
|
pub working_directory: Option<String>,
|
||||||
|
pub resources_dir: Option<String>,
|
||||||
|
pub term: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// A config with Zig-struct-style defaults for everything optional.
|
||||||
|
pub fn new(env: EnvMap, term: impl Into<String>) -> 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<Exec, Error> {
|
||||||
|
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<Output>,
|
||||||
|
surface_mailbox: Arc<dyn SurfaceMailbox>,
|
||||||
|
_renderer_state: SharedState,
|
||||||
|
) -> Result<ThreadData, Error> {
|
||||||
|
// 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<OwnedHandle>,
|
||||||
|
|
||||||
|
/// Pending writes to the pty, drained by the writer thread.
|
||||||
|
write_tx: Option<Sender<Vec<u8>>>,
|
||||||
|
write_thread: Option<JoinHandle<()>>,
|
||||||
|
write_cancel: OwnedHandle,
|
||||||
|
|
||||||
|
/// Reader thread state
|
||||||
|
read_thread: Option<JoinHandle<()>>,
|
||||||
|
read_thread_pipe: Option<OwnedHandle>,
|
||||||
|
read_cancel: OwnedHandle,
|
||||||
|
|
||||||
|
surface_mailbox: Arc<dyn SurfaceMailbox>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThreadData {
|
||||||
|
/// A handle that becomes signaled when the child exited.
|
||||||
|
pub fn process_handle(&self) -> Option<std::os::windows::io::BorrowedHandle<'_>> {
|
||||||
|
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<Instant> {
|
||||||
|
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<Vec<u8>>) {
|
||||||
|
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<String>,
|
||||||
|
env: Option<EnvMap>,
|
||||||
|
args: Vec<String>,
|
||||||
|
grid_size: GridSize,
|
||||||
|
screen_size: ScreenSize,
|
||||||
|
pty: Option<Pty>,
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Subprocess, Error> {
|
||||||
|
// 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<Shell> = 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::<Vec<_>>()
|
||||||
|
.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<String> = 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 <command>`.
|
||||||
|
pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec<String>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u16> {
|
||||||
|
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<Pty, Error> {
|
||||||
|
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::<SECURITY_ATTRIBUTES>() 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<Winsize, Error> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Instant>, 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<HANDLE> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OwnedHandle>, Option<Arc<dyn Fn() + Send + Sync>>);
|
||||||
|
|
||||||
|
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<OwnedHandle> {
|
||||||
|
// 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<Wakeup> {
|
||||||
|
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<dyn Fn() + Send + Sync>) -> io::Result<Wakeup> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<u16>, 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<Item = (&'a OsStr, &'a OsStr)>,
|
||||||
|
) -> Result<Vec<u16>, EncodeError> {
|
||||||
|
let mut result: Vec<u16> = 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<S: AsRef<str>>(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 <command>`.
|
||||||
|
pub fn windows_shell_args(windir: &str, command: &str) -> Vec<String> {
|
||||||
|
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<String> {
|
||||||
|
let chars: Vec<char> = 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<String> = argv.iter().map(|s| (*s).to_owned()).collect();
|
||||||
|
assert_eq!(back, want, "line: {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_to_entries(block: &[u16]) -> Vec<String> {
|
||||||
|
// 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<u16> = "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<u16> = "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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<dir with stub fontconfig.pc> \
|
||||||
|
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.
|
||||||
Reference in new issue
Block a user