Skip to main content

agentmux_common/
win32.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Win32 process-creation flags shared by every crate that spawns a child
5//! on Windows.
6//!
7//! Before this module existed, `CREATE_NO_WINDOW` was re-declared privately
8//! in 23 files across five crates — including twice inside this crate, in
9//! function bodies that exported it to nobody
10//! (`docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md` §2.2).
11//! The value is a Win32 ABI constant and never changes; declare it once.
12//!
13//! The flags are plain `u32`s so a spawn that needs something other than
14//! `CREATE_NO_WINDOW` alone (`CREATE_SUSPENDED | CREATE_NO_WINDOW`,
15//! `CREATE_NEW_CONSOLE`) can still pass them to `creation_flags` directly.
16//! The common case, no console window, is [`NoWindow::no_window`], which
17//! covers both `std::process::Command` and `tokio::process::Command`.
18//!
19//! Compiled on every platform so a `use agentmux_common::win32::*` never
20//! needs its own `cfg` guard; the values are only *meaningful* on Windows.
21
22/// `CREATE_NO_WINDOW` — do not allocate a console for a console-subsystem
23/// child. GUI-subsystem parents (the host, the launcher) and the
24/// windowless sidecar otherwise get a console window flashed open for every
25/// `node`, `cmd.exe`, `git`, `taskkill`, etc. they spawn.
26pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
27
28/// `CREATE_SUSPENDED` — create the child's primary thread suspended, so the
29/// parent can assign it to a Job Object before it runs a single instruction.
30pub const CREATE_SUSPENDED: u32 = 0x0000_0004;
31
32/// Spawn a child without opening a console window for it.
33///
34/// Every crate that spawns a child on Windows needs the same three lines:
35/// `#[cfg(windows)]`, `use std::os::windows::process::CommandExt`,
36/// `cmd.creation_flags(CREATE_NO_WINDOW)`. Forgetting them is silent, and shows up
37/// only as a console flashing over the splash on a user's desktop (#2171, #2173,
38/// #3788). This trait makes it one call that is a no-op off Windows, and works for
39/// both `std::process::Command` and `tokio::process::Command`.
40///
41/// `creation_flags` *replaces* the flags, it does not OR them, so a spawn that needs
42/// other flags too (`CREATE_SUSPENDED | CREATE_NO_WINDOW`) or a different one
43/// (`CREATE_NEW_CONSOLE`) still calls `creation_flags` itself.
44pub trait NoWindow {
45    fn no_window(&mut self) -> &mut Self;
46}
47
48impl NoWindow for std::process::Command {
49    #[allow(unused_mut)]
50    fn no_window(&mut self) -> &mut Self {
51        #[cfg(windows)]
52        {
53            use std::os::windows::process::CommandExt;
54            self.creation_flags(CREATE_NO_WINDOW);
55        }
56        self
57    }
58}
59
60impl NoWindow for tokio::process::Command {
61    fn no_window(&mut self) -> &mut Self {
62        // `tokio::process::Command` has `creation_flags` as an inherent method on Windows.
63        #[cfg(windows)]
64        {
65            self.creation_flags(CREATE_NO_WINDOW);
66        }
67        self
68    }
69}
70
71/// Resume the (single) main thread of a CREATE_SUSPENDED process.
72///
73/// Walks a Toolhelp32 thread snapshot to find the one thread belonging
74/// to `pid` (a freshly-spawned suspended process has only its main
75/// thread), opens it with THREAD_SUSPEND_RESUME, and ResumeThread's it.
76///
77/// Errors come from snapshot creation, OpenThread, or ResumeThread
78/// returning `(DWORD)-1`. A `ResumeThread` return of 0 means the thread
79/// was already running (impossible if the process was just created
80/// suspended) — treated as success.
81#[cfg(windows)]
82pub fn resume_main_thread(pid: u32) -> Result<(), String> {
83    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
84    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
85        CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD,
86        THREADENTRY32,
87    };
88    use windows_sys::Win32::System::Threading::{
89        OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
90    };
91
92    unsafe {
93        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
94        if snap == INVALID_HANDLE_VALUE {
95            return Err("CreateToolhelp32Snapshot failed".into());
96        }
97
98        let mut entry: THREADENTRY32 = std::mem::zeroed();
99        entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
100
101        let mut found = false;
102        if Thread32First(snap, &mut entry) != 0 {
103            loop {
104                if entry.th32OwnerProcessID == pid {
105                    let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID);
106                    if !thread.is_null() {
107                        let prev = ResumeThread(thread);
108                        CloseHandle(thread);
109                        if prev == u32::MAX {
110                            CloseHandle(snap);
111                            return Err(format!(
112                                "ResumeThread failed for tid={}",
113                                entry.th32ThreadID
114                            ));
115                        }
116                        found = true;
117                        break;
118                    }
119                }
120                entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
121                if Thread32Next(snap, &mut entry) == 0 {
122                    break;
123                }
124            }
125        }
126
127        CloseHandle(snap);
128        if !found {
129            return Err(format!("no thread found for pid={}", pid));
130        }
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// The values are Win32 ABI constants (`processthreadsapi.h`); pinning
140    /// them stops a typo during a future edit from silently changing which
141    /// flag every spawn in the workspace passes.
142    #[test]
143    fn flags_match_the_win32_abi() {
144        assert_eq!(CREATE_NO_WINDOW, 0x08000000);
145        assert_eq!(CREATE_SUSPENDED, 0x00000004);
146    }
147
148    /// `no_window` chains and compiles for both Command types on every platform; on
149    /// Windows it must have set the flag (a spawned `cmd` would otherwise be the one
150    /// that flashes), which `creation_flags` offers no way to read back, so this
151    /// pins the chaining shape and the real effect is covered by the spawn inventory.
152    #[test]
153    fn no_window_chains_on_both_command_types() {
154        let mut std_cmd = std::process::Command::new("agentmux-no-such-program");
155        std_cmd.no_window().arg("x");
156        let mut tokio_cmd = tokio::process::Command::new("agentmux-no-such-program");
157        tokio_cmd.no_window().arg("x");
158        assert_eq!(std_cmd.get_program(), "agentmux-no-such-program");
159    }
160}