Skip to main content

conpty_oxide/tokio/
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
5use std::ffi::OsStr;
6use std::fmt;
7use std::os::windows::io::{AsHandle, BorrowedHandle};
8use std::path::Path;
9
10use crate::core::child::ChildCore;
11use crate::core::session;
12use crate::core::wait::RegisteredWait;
13use crate::error::{Error, Result};
14use crate::status::ExitStatus;
15use crate::SessionOptions;
16
17use super::pty::Pty;
18use super::session::Session;
19
20/// A command to run inside an asynchronous pseudoconsole session.
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 a
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 asynchronous session with default options.
141    ///
142    /// This is synchronous because process creation itself does not block.
143    /// The returned session owns a kill-on-close Job; dropping it before
144    /// completion terminates the entire process tree.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error when the backend or pipes cannot be initialized —
149    /// including when no Tokio runtime with an enabled I/O driver is
150    /// current, which surfaces as [`crate::ErrorKind::CreateConsole`] — or
151    /// when the root process cannot be spawned.
152    pub fn spawn(&mut self) -> Result<Session> {
153        self.spawn_with(SessionOptions::default())
154    }
155
156    /// Spawns a managed asynchronous session with explicit safe options.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error when the selected backend or pipes cannot be
161    /// initialized — including when no Tokio runtime with an enabled I/O
162    /// driver is current, which surfaces as
163    /// [`crate::ErrorKind::CreateConsole`] — or when the root process cannot
164    /// be spawned.
165    pub fn spawn_with(&mut self, options: SessionOptions) -> Result<Session> {
166        let (size, backend) = options.into_parts();
167        let mut builder = Pty::builder().size(size);
168        if let Some(backend) = backend {
169            builder = builder.backend(backend);
170        }
171        let pty = builder.build()?;
172
173        let child = self.spawn_in_with_policy(&pty, true)?;
174        let controller = pty.controller();
175        let (output, input) = pty.into_split();
176        Ok(Session {
177            child,
178            output,
179            input,
180            controller,
181        })
182    }
183
184    /// Spawns the command as the root child of an existing low-level `pty`.
185    ///
186    /// Like `tokio::process::Command::spawn`, this is a synchronous method
187    /// even though the resulting [`Child`] is awaited: `CreateProcessW` does
188    /// not block, so there is nothing to yield for. The [`Pty`] itself must
189    /// already have been built inside a Tokio runtime.
190    ///
191    /// The session's shutdown strategy is armed as part of this call, in the
192    /// order `ConPTY` requires: the child is created, the pseudoconsole is
193    /// released when possible, and a root watcher is always installed to end
194    /// remaining Job members. Legacy sessions also use it to force output EOF.
195    ///
196    /// A pseudoconsole hosts exactly one root child; spawning into a `Pty`
197    /// that already has one fails with a
198    /// [`std::io::ErrorKind::AlreadyExists`]
199    /// source. (Descendants are unrestricted — the child may create as many as
200    /// it likes, and they all join the same job object.)
201    ///
202    /// # Errors
203    ///
204    /// An error with [`crate::ErrorKind::Spawn`] carrying the program name and
205    /// the underlying failure:
206    /// [`std::io::ErrorKind::NotFound`] for a missing executable,
207    /// [`std::io::ErrorKind::InvalidInput`] for a command line or environment
208    /// block that cannot be built, [`std::io::ErrorKind::AlreadyExists`] for a
209    /// re-used `Pty`, or the raw OS error from `CreateProcessW`.
210    #[cfg(test)]
211    pub(crate) fn spawn_in(&mut self, pty: &Pty) -> Result<Child> {
212        self.spawn_in_with_policy(pty, self.inner.get_kill_on_drop())
213    }
214
215    fn spawn_in_with_policy(&mut self, pty: &Pty, kill_on_drop: bool) -> Result<Child> {
216        let root = session::spawn_root(&pty.inner, &mut self.inner, kill_on_drop)?;
217        Ok(Child {
218            core: ChildCore::from_root(root),
219            exit: None,
220        })
221    }
222}
223
224/// A running (or finished) root child of an asynchronous pseudoconsole
225/// session.
226///
227/// The handle owns the session's job object as well as the process handle, so
228/// [`Child::kill`] terminates the whole process tree rather than just the
229/// process this crate created.
230///
231/// Every publicly obtainable `Child` is managed and kill-on-drop. Dropping it
232/// does not wait; the Job terminates the root process and every descendant
233/// still running.
234///
235/// The process handle is available only through the lifetime-safe
236/// [`AsHandle`] implementation; a hidden compile-fail doctest pins the
237/// missing raw-handle escape hatch.
238pub struct Child {
239    core: ChildCore,
240    /// The in-flight Windows thread-pool wait. It stays in the child when a
241    /// caller cancels `wait`, so a later call resumes the same registration.
242    exit: Option<RegisteredWait>,
243}
244
245/// Shows the child's identity — pid, drop policy, and any cached exit status
246/// — rather than the raw process and job handles, whose values are noise that
247/// varies between runs.
248impl fmt::Debug for Child {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        self.core.fmt(f)
251    }
252}
253
254impl Child {
255    /// Returns the child's process identifier.
256    ///
257    /// The identifier stays valid as long as this `Child` is alive; once the
258    /// process handle is closed, Windows may reuse the number.
259    #[must_use]
260    pub const fn id(&self) -> u32 {
261        self.core.id()
262    }
263
264    /// Waits for the child to exit and returns its status.
265    ///
266    /// Repeated calls return the cached status instead of waiting again. The
267    /// registered wait uses only the supplied task [`Waker`](std::task::Waker);
268    /// the wait itself does not occupy or require a Tokio runtime thread.
269    ///
270    /// # Deadlock
271    ///
272    /// The child must be able to make progress while this is pending, which
273    /// means something else has to drain the session's output — see the module
274    /// docs.
275    ///
276    /// # Cancel safety
277    ///
278    /// This method is cancel-safe. Dropping the returned future loses no
279    /// progress and no exit status: one Windows
280    /// `RegisterWaitForSingleObject` registration is stored in the `Child`, so
281    /// a later call resumes it. No Tokio worker or blocking-pool thread is
282    /// occupied while the process is alive, and runtime shutdown is therefore
283    /// independent of a pending child wait.
284    ///
285    /// # Errors
286    ///
287    /// An error with [`crate::ErrorKind::Wait`] wrapping the OS error from duplicating the process
288    /// handle, `RegisterWaitForSingleObject`, or `GetExitCodeProcess`.
289    pub async fn wait(&mut self) -> Result<ExitStatus> {
290        if let Some(status) = self.core.status() {
291            return Ok(status);
292        }
293
294        if self.exit.is_none() {
295            self.exit = Some(RegisteredWait::new(self.core.as_handle()).map_err(Error::wait)?);
296        }
297        let wait = self.exit.as_mut().ok_or_else(|| {
298            Error::wait(std::io::Error::other(
299                "the registered process wait was not initialized",
300            ))
301        })?;
302        let result = wait.await;
303        self.exit = None;
304        let code = result.map_err(Error::wait)?;
305        Ok(self.core.cache_exit_code(code))
306    }
307
308    /// Returns the exit status if the child has already exited, without
309    /// waiting.
310    ///
311    /// A plain synchronous method: the underlying poll is a zero-timeout wait
312    /// on the process handle, which never blocks.
313    ///
314    /// # Errors
315    ///
316    /// An error with [`crate::ErrorKind::Wait`] wrapping the OS error from
317    /// `WaitForSingleObject` or
318    /// `GetExitCodeProcess`.
319    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
320        let status = self.core.try_wait()?;
321        if status.is_some() {
322            self.exit = None;
323        }
324        Ok(status)
325    }
326
327    #[cfg(test)]
328    pub(crate) const fn cached_status(&self) -> Option<ExitStatus> {
329        self.core.status()
330    }
331
332    /// Terminates the child and every descendant it created.
333    ///
334    /// This terminates the session's job object, so processes the child
335    /// spawned are killed too. Termination is asynchronous as far as Windows
336    /// is concerned: await [`Child::wait`] afterwards to observe the resulting
337    /// status, which is exit code `1`.
338    ///
339    /// Killing an already-finished tree succeeds and does nothing.
340    ///
341    /// # Errors
342    ///
343    /// An error with [`crate::ErrorKind::Kill`] wrapping the OS error from
344    /// `TerminateJobObject`.
345    pub fn kill(&mut self) -> Result<()> {
346        self.core.kill()
347    }
348}
349
350/// Borrows the child's process handle, e.g. to duplicate it or to wait on it
351/// together with other objects.
352impl AsHandle for Child {
353    fn as_handle(&self) -> BorrowedHandle<'_> {
354        self.core.as_handle()
355    }
356}
357
358/// The API boundaries stated on [`Command`] and [`Child`], pinned as
359/// compile-fail doctests without rendering as examples.
360///
361/// A command is never `Clone`:
362///
363/// ```compile_fail
364/// use conpty_oxide::tokio::Command;
365///
366/// fn requires_clone<T: Clone>() {}
367/// requires_clone::<Command>();
368/// ```
369///
370/// Low-level lifecycle and unvalidated process flags stay absent:
371///
372/// ```compile_fail
373/// let mut command = conpty_oxide::tokio::Command::new("cmd.exe");
374/// command.creation_flags(0);
375/// ```
376///
377/// ```compile_fail
378/// let mut command = conpty_oxide::tokio::Command::new("cmd.exe");
379/// command.kill_on_drop(false);
380/// ```
381///
382/// ```compile_fail
383/// let mut command = conpty_oxide::tokio::Command::new("cmd.exe");
384/// command.spawn_in(());
385/// ```
386///
387/// The child exposes no raw process handle:
388///
389/// ```compile_fail
390/// use std::os::windows::io::AsRawHandle;
391///
392/// fn requires_raw_handle<T: AsRawHandle>() {}
393/// requires_raw_handle::<conpty_oxide::tokio::Child>();
394/// ```
395#[cfg(doctest)]
396mod api_boundary {}