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}