conpty_oxide/blocking/command.rs
1// SPDX-FileCopyrightText: 2026 conpty-oxide contributors <https://github.com/P4suta/conpty-oxide/graphs/contributors>
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Blocking command construction and root-child process ownership.
6
7use std::ffi::OsStr;
8use std::fmt;
9use std::os::windows::io::{AsHandle, BorrowedHandle};
10use std::path::Path;
11
12use super::pty::Pty;
13use super::session::Session;
14use crate::core::child::ChildCore;
15use crate::core::session;
16use crate::error::Result;
17use crate::status::ExitStatus;
18use crate::SessionOptions;
19
20/// A command to run inside a pseudoconsole.
21///
22/// Mirrors [`std::process::Command`]: the builder methods take `&mut self` and
23/// return `&mut Self`, so a whole invocation can be written as one expression.
24/// The differences from the standard library are the ones a pseudoconsole
25/// forces:
26///
27/// - There is no stdio configuration. The child's console *is* the
28/// pseudoconsole; its standard handles are deliberately set to
29/// `INVALID_HANDLE_VALUE` so a redirected parent cannot leak its own stdio
30/// into the child.
31/// - No handles are inherited (`bInheritHandles` is `FALSE`), because a leaked
32/// copy of the output pipe would keep the session from ever reaching
33/// end-of-file.
34/// - The child and every descendant it creates join a job object, which is
35/// what makes [`Child::kill`] terminate the whole tree.
36///
37/// A command is intentionally not `Clone`: managed spawning must not copy or
38/// mutate its potentially large argument and environment buffers. Low-level
39/// lifecycle and unvalidated process flags are intentionally absent; hidden
40/// compile-fail doctests pin both boundaries.
41#[derive(Debug)]
42pub struct Command {
43 inner: crate::command::Command,
44}
45
46impl Command {
47 /// Creates a builder for launching `program`.
48 ///
49 /// The program is not resolved here; a missing executable surfaces as
50 /// an error with [`crate::ErrorKind::Spawn`] and an
51 /// [`std::io::ErrorKind::NotFound`] source.
52 #[must_use]
53 pub fn new(program: impl AsRef<OsStr>) -> Self {
54 Self {
55 inner: crate::command::Command::new(program),
56 }
57 }
58
59 /// Appends one argument, quoted and escaped as the MSVC C runtime expects.
60 pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
61 self.inner.arg(arg);
62 self
63 }
64
65 /// Appends several arguments; equivalent to calling [`Command::arg`] for
66 /// each one.
67 pub fn args<I, S>(&mut self, args: I) -> &mut Self
68 where
69 I: IntoIterator<Item = S>,
70 S: AsRef<OsStr>,
71 {
72 self.inner.args(args);
73 self
74 }
75
76 /// Appends literal text to the command line, bypassing all quoting.
77 ///
78 /// Same semantics as `std::os::windows::process::CommandExt::raw_arg`:
79 /// intended for callees such as `cmd.exe /c` that parse the raw command
80 /// line themselves.
81 pub fn raw_arg(&mut self, text: impl AsRef<OsStr>) -> &mut Self {
82 self.inner.raw_arg(text);
83 self
84 }
85
86 /// Sets an environment variable for the child (case-insensitively, as
87 /// Windows does).
88 pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
89 self.inner.env(key, value);
90 self
91 }
92
93 /// Sets several environment variables; equivalent to calling
94 /// [`Command::env`] for each pair.
95 pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
96 where
97 I: IntoIterator<Item = (K, V)>,
98 K: AsRef<OsStr>,
99 V: AsRef<OsStr>,
100 {
101 self.inner.envs(vars);
102 self
103 }
104
105 /// Removes an environment variable from the child's environment.
106 pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
107 self.inner.env_remove(key);
108 self
109 }
110
111 /// Clears the child's environment, including modifications recorded so
112 /// far. Variables set afterwards still apply.
113 pub fn env_clear(&mut self) -> &mut Self {
114 self.inner.env_clear();
115 self
116 }
117
118 /// Sets the child's working directory.
119 pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
120 self.inner.current_dir(dir);
121 self
122 }
123
124 /// Terminates the child's whole process tree when its [`Child`] is
125 /// dropped. Defaults to `false`.
126 ///
127 /// This policy applies to [`Command::spawn_in`]. Managed
128 /// [`Command::spawn`] sessions always enable kill-on-drop and
129 /// kill-on-Job-close regardless of this setting.
130 ///
131 /// This also sets `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` on the session's
132 /// job object, so the tree is terminated by the kernel even if this
133 /// process dies without running any destructor.
134 #[cfg(test)]
135 pub(crate) fn kill_on_drop(&mut self, kill: bool) -> &mut Self {
136 self.inner.kill_on_drop(kill);
137 self
138 }
139
140 /// Spawns a managed session with default options.
141 ///
142 /// The returned session owns its pseudoconsole, I/O, child process, and a
143 /// kill-on-close Job. Dropping it before completion terminates the entire
144 /// process tree.
145 ///
146 /// # Errors
147 ///
148 /// Returns an error when the backend or pipes cannot be initialized, or
149 /// when the root process cannot be spawned.
150 pub fn spawn(&mut self) -> Result<Session> {
151 self.spawn_with(SessionOptions::default())
152 }
153
154 /// Spawns a managed session with explicit safe options.
155 ///
156 /// # Errors
157 ///
158 /// Returns an error when the selected backend or pipes cannot be
159 /// initialized, or when the root process cannot be spawned.
160 pub fn spawn_with(&mut self, options: SessionOptions) -> Result<Session> {
161 let (size, backend) = options.into_parts();
162 let mut builder = Pty::builder().size(size);
163 if let Some(backend) = backend {
164 builder = builder.backend(backend);
165 }
166 let pty = builder.build()?;
167
168 // Managed ownership is intentionally stricter than the low-level
169 // command policy. Passing the policy to this spawn leaves the builder
170 // untouched for a later low-level `spawn_in` call.
171 let child = self.spawn_in_with_policy(&pty, true)?;
172 let controller = pty.controller();
173 let (output, input) = pty.into_split();
174 Ok(Session::new(child, output, input, controller))
175 }
176
177 /// Spawns the command as the root child of an existing low-level `pty`.
178 ///
179 /// The session's shutdown strategy is armed as part of this call, in the
180 /// order `ConPTY` requires: the child is created, the pseudoconsole is
181 /// released when possible, and a root watcher is always installed to end
182 /// remaining Job members. Legacy sessions also use it to force output EOF.
183 ///
184 /// A pseudoconsole hosts exactly one root child; spawning into a `Pty`
185 /// that already has one fails with an
186 /// [`std::io::ErrorKind::AlreadyExists`] source. (Descendants are
187 /// unrestricted — the child may create as many as it likes, and they all
188 /// join the same job object.)
189 ///
190 /// # Errors
191 ///
192 /// An error with [`crate::ErrorKind::Spawn`] carrying the program name and the
193 /// underlying failure: [`std::io::ErrorKind::NotFound`] for a missing
194 /// executable, [`std::io::ErrorKind::InvalidInput`] for a command line or
195 /// environment block that cannot be built,
196 /// [`std::io::ErrorKind::AlreadyExists`] for a re-used `Pty`, or the raw OS
197 /// error from `CreateProcessW`.
198 #[cfg(test)]
199 pub(crate) fn spawn_in(&mut self, pty: &Pty) -> Result<Child> {
200 self.spawn_in_with_policy(pty, self.inner.get_kill_on_drop())
201 }
202
203 fn spawn_in_with_policy(&mut self, pty: &Pty, kill_on_drop: bool) -> Result<Child> {
204 let root = session::spawn_root(&pty.inner, &mut self.inner, kill_on_drop)?;
205 Ok(Child {
206 core: ChildCore::from_root(root),
207 })
208 }
209}
210
211/// A running (or finished) root child of a pseudoconsole session.
212///
213/// The handle owns the session's job object as well as the process handle, so
214/// [`Child::kill`] terminates the whole process tree rather than just the
215/// process this crate created.
216///
217/// Every publicly obtainable `Child` is managed and kill-on-drop. Dropping it
218/// does not wait; the Job terminates the root process and every descendant
219/// still running.
220///
221/// The process handle is available only through the lifetime-safe
222/// [`AsHandle`] implementation; a hidden compile-fail doctest pins the
223/// missing raw-handle escape hatch.
224pub struct Child {
225 core: ChildCore,
226}
227
228/// Shows the child's identity — pid, drop policy, and any cached exit status
229/// — rather than the raw process and job handles, whose values are noise that
230/// varies between runs.
231impl fmt::Debug for Child {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 self.core.fmt(f)
234 }
235}
236
237impl Child {
238 /// Returns the child's process identifier.
239 ///
240 /// The identifier stays valid as long as this `Child` is alive; once the
241 /// process handle is closed, Windows may reuse the number.
242 #[must_use]
243 pub const fn id(&self) -> u32 {
244 self.core.id()
245 }
246
247 /// Waits for the child to exit and returns its status.
248 ///
249 /// Repeated calls return the cached status instead of waiting again.
250 ///
251 /// # Deadlock
252 ///
253 /// The child must be able to make progress while this blocks, which means
254 /// something else has to drain the session's output — see the module docs.
255 ///
256 /// # Errors
257 ///
258 /// An error with [`crate::ErrorKind::Wait`] wrapping the OS error from
259 /// `WaitForSingleObject` or `GetExitCodeProcess`.
260 pub fn wait(&mut self) -> Result<ExitStatus> {
261 self.core.wait_blocking()
262 }
263
264 /// Returns the exit status if the child has already exited, without
265 /// blocking.
266 ///
267 /// # Errors
268 ///
269 /// An error with [`crate::ErrorKind::Wait`] wrapping the OS error from
270 /// `WaitForSingleObject` or `GetExitCodeProcess`.
271 pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
272 self.core.try_wait()
273 }
274
275 /// Terminates the child and every descendant it created.
276 ///
277 /// This terminates the session's job object, so processes the child
278 /// spawned are killed too. Termination is asynchronous: call
279 /// [`Child::wait`] afterwards to observe the resulting status, which is
280 /// exit code `1`.
281 ///
282 /// Killing an already-finished tree succeeds and does nothing.
283 ///
284 /// # Errors
285 ///
286 /// An error with [`crate::ErrorKind::Kill`] wrapping the OS error from
287 /// `TerminateJobObject`.
288 pub fn kill(&mut self) -> Result<()> {
289 self.core.kill()
290 }
291}
292
293/// Borrows the child's process handle, e.g. to duplicate it or to wait on it
294/// together with other objects.
295impl AsHandle for Child {
296 fn as_handle(&self) -> BorrowedHandle<'_> {
297 self.core.as_handle()
298 }
299}
300
301/// The API boundaries stated on [`Command`] and [`Child`], pinned as
302/// compile-fail doctests without rendering as examples.
303///
304/// A command is never `Clone`:
305///
306/// ```compile_fail
307/// use conpty_oxide::blocking::Command;
308///
309/// fn requires_clone<T: Clone>() {}
310/// requires_clone::<Command>();
311/// ```
312///
313/// Low-level lifecycle and unvalidated process flags stay absent:
314///
315/// ```compile_fail
316/// let mut command = conpty_oxide::blocking::Command::new("cmd.exe");
317/// command.creation_flags(0);
318/// ```
319///
320/// ```compile_fail
321/// let mut command = conpty_oxide::blocking::Command::new("cmd.exe");
322/// command.kill_on_drop(false);
323/// ```
324///
325/// ```compile_fail
326/// let mut command = conpty_oxide::blocking::Command::new("cmd.exe");
327/// command.spawn_in(());
328/// ```
329///
330/// The child exposes no raw process handle:
331///
332/// ```compile_fail
333/// use std::os::windows::io::AsRawHandle;
334///
335/// fn requires_raw_handle<T: AsRawHandle>() {}
336/// requires_raw_handle::<conpty_oxide::blocking::Child>();
337/// ```
338#[cfg(doctest)]
339mod api_boundary {}