Skip to main content

running_process_platform_internal/
lib.rs

1//! Blessed asynchronous process operations.
2//!
3//! This crate is intentionally published as an implementation detail. It is
4//! the only production owner of the Tokio process primitives used by the
5//! async process API. Higher layers receive typed operations and never name
6//! `tokio::process::Command` directly.
7
8use std::cfg_select;
9/// Explicit caller-owned foreground command execution.
10pub mod foreground;
11mod semantic_priority;
12pub use semantic_priority::ProcessPriority;
13#[cfg(feature = "async-process")]
14mod spawn_admission;
15#[cfg(feature = "async-process")]
16pub use spawn_admission::SpawnAdmission;
17#[cfg(feature = "async-process")]
18use std::ffi::{OsStr, OsString};
19#[cfg(feature = "async-process")]
20use std::io;
21#[cfg(feature = "async-process")]
22use std::path::PathBuf;
23#[cfg(feature = "async-process")]
24use std::process::{ExitStatus, Output, Stdio};
25
26#[cfg(feature = "async-process")]
27use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
28#[cfg(feature = "async-process")]
29use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
30
31/// Neutral capability indexes for the eventual workspace-wide host boundary.
32///
33/// The indexes intentionally expose no operations yet: phase 2 establishes
34/// ownership names before later phases move a capability behind them.
35pub mod platform;
36
37#[cfg(all(feature = "independent-spawn", test, not(windows)))]
38#[path = "platform_win/scheduler_error.rs"]
39mod scheduler_error;
40#[cfg(feature = "independent-spawn")]
41pub(crate) use platform_imp::spawn_sync_owned_daemon;
42#[cfg(feature = "independent-spawn")]
43pub use platform_imp::{
44    independent_broker_run, independent_broker_spawn, independent_spawn, IndependentChild,
45};
46#[cfg(feature = "independent-spawn")]
47pub(crate) use platform_imp::{independent_open_regular, INDEPENDENT_ZERO_WRITE_PENDING};
48
49/// Temporary source-compatibility re-export for the pre-boundary PTY API.
50///
51/// New code must use [`platform::terminal`] facade-owned types. This root-only
52/// alias deliberately stays outside the neutral facade and can be removed in
53/// the next major release after downstream users have migrated.
54#[cfg(feature = "pty")]
55#[doc(hidden)]
56pub use portable_pty as portable_pty_compat;
57
58// This is deliberately the crate's only host selector.  Facade modules are
59// neutral; native details live behind the selected private root.
60cfg_select! {
61    target_os = "windows" => {
62        mod platform_win;
63        pub(crate) use platform_win as platform_imp;
64    }
65    target_os = "linux" => {
66        mod platform_linux;
67        pub(crate) use platform_linux as platform_imp;
68    }
69    target_os = "macos" => {
70        mod platform_macos;
71        pub(crate) use platform_macos as platform_imp;
72    }
73}
74
75// Re-export the selected implementation once from this allowed host-selector
76// root. Neutral capability facades re-export only crate-root names and never
77// name the private `platform_imp` alias themselves.
78pub use platform_imp::{
79    assign_child_to_windows_job, cancel_capture_reader, canonical_environment_pairs,
80    capture_reader_done, compat_shell_command, configure_exact_trace, configure_process_command,
81    configure_process_command_for_bounded_owner_death, configure_sync_contained_command,
82    configure_sync_daemon_command, configure_sync_daemon_command_with_inheritance,
83    configure_trampoline_command, current_executable_build_id, exact_trace_capability, exit_code,
84    monitor_console_windows, parent_has_console, prepare_capture_reader, set_process_name,
85    shell_command, soft_terminate_process_group, spawn_sync, spawn_sync_daemon,
86    spawn_sync_daemon_with_inheritance, start_descendant_monitor, start_exact_trace,
87    sync_child_native_handle, trampoline_exit_code, unix_mark_extra_fds_close_on_exec,
88    unix_set_priority, unix_signal_process, unix_signal_process_group, unix_signal_raw,
89    CaptureCancellation, TracedChild, WindowsJobHandle,
90};
91
92#[cfg(feature = "terminal-graphics")]
93pub use platform_imp::active_graphics_probe;
94
95#[cfg(feature = "window-icon")]
96pub use platform_imp::{set_window_icon_impl, window_icon_support_impl};
97
98#[cfg(feature = "async-process")]
99pub(crate) use platform_imp::{
100    async_child_cpu_time, async_child_identity, signal_async_child, signal_async_child_group,
101    AsyncChildIdentity,
102};
103
104#[cfg(feature = "process-inspection")]
105pub use platform_imp::{kill_tree, process_snapshot, process_snapshot_for_pid};
106
107pub use platform_imp::{autostart_register, autostart_render_registration, autostart_unregister};
108
109pub use platform_imp::{process_install_owner_death_cleanup, process_owner_death_cleanup_target};
110
111pub use platform_imp::process_install_shutdown_request_handler;
112
113pub use platform_imp::fs_write_all_to_descriptor;
114
115pub use platform_imp::{process_can_replace_current_image, process_replace_current_image};
116
117pub use platform_imp::{
118    process_executable_path, process_force_kill, process_same_executable_path,
119    process_signal_terminate, ProcessLiveness,
120};
121
122pub use platform_imp::{
123    resources_fd_exhaustion_error, resources_inode_capacity, resources_signals_fd_exhaustion,
124    resources_signals_storage_exhaustion, resources_storage_exhaustion_error,
125};
126
127pub use platform_imp::{
128    executable_file_name, executable_sibling_of_current_image, EXECUTABLE_EXTENSION,
129};
130
131#[cfg(feature = "fs")]
132pub use platform_imp::{
133    fs_create_private_file, fs_decode_path_bytes, fs_encode_path_bytes, fs_file_identity,
134    fs_is_lock_conflict, fs_open_lock_file, fs_path_identity, fs_replace_file, fs_sync_directory,
135    fs_try_lock_exclusive, fs_unlock, fs_user_config_dir, fs_user_data_dir, fs_user_run_data_root,
136    fs_user_runtime_dir, fs_user_state_dir, FsFileIdentity,
137};
138
139pub use platform_imp::{
140    host_boot_id, host_current_process_privilege, host_environment_keys_are_case_insensitive,
141    host_filesystem_device_id, host_hostname, host_login_environment, host_machine_id,
142    host_namespace_id, host_user_machine_identity, HostPrivilegedIdentity,
143};
144
145pub use platform_imp::host_login_environment_block;
146
147pub use platform_imp::terminal_input;
148
149#[cfg(feature = "ipc")]
150pub use platform_imp::{
151    ipc_broker_endpoint_name as IpcBrokerEndpointName, ipc_broker_v1_endpoint_path,
152    ipc_broker_v2_runtime_dir, ipc_current_user_id, ipc_endpoint_is_filesystem_backed,
153    ipc_endpoint_name_limit, ipc_endpoint_scope_bytes, ipc_nonblocking_zero_read_is_pending,
154    ipc_select_endpoint_address, IpcEndpoint, IpcInheritedListener, IpcListener,
155    IpcListenerNonblockingMode, IpcPeerIdentity, IpcPeerIdentitySource, IpcStream,
156};
157
158#[cfg(feature = "private-dir")]
159pub use platform_imp::{
160    private_dir_ensure_owner_private_directory, private_dir_owner_private_directory,
161};
162
163// Retain the implementation-detail root aliases selected by the historical
164// `ipc` capability.  New callers use `platform::private_dir` instead.
165#[cfg(feature = "ipc")]
166pub use platform_imp::{
167    private_dir_ensure_owner_private_directory as ipc_ensure_owner_private_directory,
168    private_dir_owner_private_directory as ipc_owner_private_directory,
169};
170
171/// Failure details for the deprecated 4.x raw descriptor/handle handoff API.
172///
173/// This type exists only at the crate-root compatibility boundary. New product
174/// mechanics use opaque [`platform::ipc::Stream`] operations instead.
175#[cfg(feature = "ipc")]
176#[doc(hidden)]
177#[derive(Clone, Debug, PartialEq, Eq)]
178pub struct LegacyHandoffError {
179    kind: platform::ipc::HandoffTransferErrorKind,
180    raw_os_error: Option<i32>,
181    transferred_bytes: Option<usize>,
182    expected_bytes: Option<usize>,
183    detail: Option<String>,
184}
185
186#[cfg(feature = "ipc")]
187impl LegacyHandoffError {
188    pub(crate) fn new(
189        kind: platform::ipc::HandoffTransferErrorKind,
190        raw_os_error: Option<i32>,
191    ) -> Self {
192        Self {
193            kind,
194            raw_os_error,
195            transferred_bytes: None,
196            expected_bytes: None,
197            detail: None,
198        }
199    }
200
201    pub(crate) fn with_detail(
202        kind: platform::ipc::HandoffTransferErrorKind,
203        raw_os_error: Option<i32>,
204        detail: impl Into<String>,
205    ) -> Self {
206        Self {
207            kind,
208            raw_os_error,
209            transferred_bytes: None,
210            expected_bytes: None,
211            detail: Some(detail.into()),
212        }
213    }
214
215    #[doc(hidden)]
216    pub fn partial(transferred_bytes: usize, expected_bytes: usize) -> Self {
217        Self {
218            kind: platform::ipc::HandoffTransferErrorKind::Failed,
219            raw_os_error: None,
220            transferred_bytes: Some(transferred_bytes),
221            expected_bytes: Some(expected_bytes),
222            detail: Some(format!(
223                "SCM_RIGHTS connection transfer was partial ({transferred_bytes}/{expected_bytes} bytes)"
224            )),
225        }
226    }
227
228    /// Return the policy-neutral failure category.
229    pub fn kind(&self) -> platform::ipc::HandoffTransferErrorKind {
230        self.kind
231    }
232
233    /// Return the native error code retained for legacy public diagnostics.
234    pub fn raw_os_error(&self) -> Option<i32> {
235        self.raw_os_error
236    }
237
238    /// Return a partial payload count when the descriptor may have transferred.
239    pub fn partial_counts(&self) -> Option<(usize, usize)> {
240        self.transferred_bytes.zip(self.expected_bytes)
241    }
242
243    pub(crate) fn detail(&self) -> Option<&str> {
244        self.detail.as_deref()
245    }
246}
247
248/// Whether the deprecated 4.x SCM_RIGHTS compatibility transport is available.
249#[cfg(feature = "ipc")]
250#[doc(hidden)]
251pub const LEGACY_SCM_RIGHTS_TRANSPORT_SUPPORTED: bool =
252    platform_imp::LEGACY_SCM_RIGHTS_TRANSPORT_SUPPORTED;
253
254/// Whether the deprecated 4.x DuplicateHandle compatibility transport is available.
255#[cfg(feature = "ipc")]
256#[doc(hidden)]
257pub const LEGACY_DUPLICATE_HANDLE_TRANSPORT_SUPPORTED: bool =
258    platform_imp::LEGACY_DUPLICATE_HANDLE_TRANSPORT_SUPPORTED;
259
260/// Root-only adapter for the deprecated raw-descriptor handoff API.
261#[cfg(feature = "ipc")]
262#[doc(hidden)]
263pub fn legacy_send_fd_to(
264    socket: &std::path::Path,
265    sent_fd: i32,
266    payload: &[u8],
267) -> Result<(), LegacyHandoffError> {
268    platform_imp::legacy_send_fd_to(socket, sent_fd, payload)
269}
270
271/// Root-only adapter for the deprecated connected raw-descriptor handoff API.
272#[cfg(feature = "ipc")]
273#[doc(hidden)]
274pub fn legacy_send_fd_over(
275    socket_fd: i32,
276    sent_fd: i32,
277    payload: &[u8],
278) -> Result<(), LegacyHandoffError> {
279    platform_imp::legacy_send_fd_over(socket_fd, sent_fd, payload)
280}
281
282/// Root-only adapter for the deprecated raw-handle duplication API.
283#[cfg(feature = "ipc")]
284#[doc(hidden)]
285pub fn legacy_duplicate_handle(
286    source_handle: usize,
287    backend_pid: u32,
288) -> Result<usize, LegacyHandoffError> {
289    platform_imp::legacy_duplicate_handle(source_handle, backend_pid)
290}
291
292/// Temporary source-compatibility conversion for public APIs that predate the
293/// opaque IPC facade.
294///
295/// New code must keep [`IpcStream`] opaque. This root-only adapter exists so
296/// `running-process` can preserve its established raw-stream callback contract
297/// until the next major release without exposing the transport through
298/// [`platform::ipc`].
299#[cfg(feature = "ipc")]
300#[doc(hidden)]
301pub fn into_legacy_ipc_stream(stream: IpcStream) -> interprocess::local_socket::Stream {
302    platform_imp::into_legacy_ipc_stream(stream)
303}
304
305/// Temporary source-compatibility conversion for legacy 4.x callback inputs.
306#[cfg(feature = "ipc")]
307#[doc(hidden)]
308pub fn from_legacy_ipc_stream(stream: interprocess::local_socket::Stream) -> IpcStream {
309    platform_imp::from_legacy_ipc_stream(stream)
310}
311
312/// Temporary source-compatibility conversion for public APIs that return an
313/// `interprocess` endpoint name.
314#[cfg(feature = "ipc")]
315#[doc(hidden)]
316pub fn legacy_ipc_name(path: &str) -> Result<interprocess::local_socket::Name<'_>, String> {
317    platform_imp::legacy_ipc_name(path)
318}
319
320#[cfg(feature = "ipc-async")]
321pub use platform_imp::{
322    IpcAsyncListener, IpcAsyncStream, IpcIntoAsyncListener, IpcIntoAsyncStream,
323};
324
325#[cfg(feature = "pty")]
326pub use platform_imp::terminal::{
327    before_pty_spawn, current_backend_kind, find_child_processes, find_orphan_conhosts,
328    input_payload, is_ignorable_process_control_error, prepare_unmanaged_pty_child,
329    query_responses, resize_pty, shell_argv, signal_pty_tree, terminate_pty_child,
330    wait_before_pty_close_supported, Backend, ChildProcessInfo, ConPtyBackendKind,
331    OrphanConhostInfo, PtyProcessGuard, PtySpawnContext, TerminalInputSession,
332};
333
334#[cfg(feature = "session-relay")]
335pub use platform_imp::relay_local_socket_session;
336
337/// Apply host-owned setup for the legacy Tokio-command compatibility surface.
338///
339/// The public wrapper retains its policy type, while console suppression and
340/// owner-death primitives stay inside the selected platform root.
341#[cfg(feature = "async-process")]
342pub fn configure_compat_tokio_command(
343    command: &mut Command,
344    show_console: bool,
345    kill_when_owner_dies: bool,
346) -> io::Result<()> {
347    platform_imp::configure_compat_tokio_command(command, show_console, kill_when_owner_dies)
348}
349
350/// Complete host-owned setup after a legacy Tokio child has been spawned.
351#[cfg(feature = "async-process")]
352pub fn after_compat_tokio_spawn(child: &Child, kill_when_owner_dies: bool) -> io::Result<()> {
353    platform_imp::after_compat_tokio_spawn(child, kill_when_owner_dies)
354}
355
356/// Stdio policy for one child stream.
357#[cfg(feature = "async-process")]
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum StreamMode {
360    /// Leave the stream connected to the parent process.
361    Inherit,
362    /// Create an asynchronous pipe owned by the child handle.
363    Piped,
364    /// Connect the stream to the platform null device.
365    Null,
366}
367
368#[cfg(feature = "async-process")]
369impl StreamMode {
370    fn apply(self) -> Stdio {
371        match self {
372            Self::Inherit => Stdio::inherit(),
373            Self::Piped => Stdio::piped(),
374            Self::Null => Stdio::null(),
375        }
376    }
377}
378
379/// Typed spawn description accepted by the blessed process boundary.
380#[cfg(feature = "async-process")]
381#[derive(Debug, Clone)]
382pub struct SpawnSpec {
383    program: OsString,
384    args: Vec<OsString>,
385    current_dir: Option<PathBuf>,
386    env: Vec<(OsString, OsString)>,
387    clear_env: bool,
388    stdin: StreamMode,
389    stdout: StreamMode,
390    stderr: StreamMode,
391    create_process_group: bool,
392    kill_when_owner_dies: bool,
393    nice: Option<i32>,
394    admission: Option<SpawnAdmission>,
395}
396
397#[cfg(feature = "async-process")]
398impl SpawnSpec {
399    /// Create a direct (non-shell) command description.
400    pub fn new(program: impl Into<OsString>) -> Self {
401        Self {
402            program: program.into(),
403            args: Vec::new(),
404            current_dir: None,
405            env: Vec::new(),
406            clear_env: false,
407            stdin: StreamMode::Inherit,
408            stdout: StreamMode::Inherit,
409            stderr: StreamMode::Inherit,
410            create_process_group: false,
411            kill_when_owner_dies: false,
412            nice: None,
413            admission: None,
414        }
415    }
416
417    /// Append one argument without requiring UTF-8.
418    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
419        self.args.push(arg.into());
420        self
421    }
422
423    /// Set the child working directory.
424    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
425        self.current_dir = Some(path.into());
426        self
427    }
428
429    /// Add an environment override.
430    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
431        self.env.push((key.into(), value.into()));
432        self
433    }
434
435    /// Start with an empty inherited environment before applying overrides.
436    pub fn clear_env(mut self, clear: bool) -> Self {
437        self.clear_env = clear;
438        self
439    }
440
441    /// Configure child stdin.
442    pub fn stdin(mut self, mode: StreamMode) -> Self {
443        self.stdin = mode;
444        self
445    }
446
447    /// Configure child stdout.
448    pub fn stdout(mut self, mode: StreamMode) -> Self {
449        self.stdout = mode;
450        self
451    }
452
453    /// Configure child stderr.
454    pub fn stderr(mut self, mode: StreamMode) -> Self {
455        self.stderr = mode;
456        self
457    }
458
459    /// Put the child in its own process group.
460    ///
461    /// This is what makes a group-wide soft signal addressable at all:
462    /// [`PlatformEmergencySignal::terminate_group_soft`] is a no-op without
463    /// it, because on POSIX the negative-PID signal would otherwise reach the
464    /// caller's own group, and on Windows `GenerateConsoleCtrlEvent` only
465    /// routes to children spawned with `CREATE_NEW_PROCESS_GROUP`. It also
466    /// detaches the child from the parent's console Ctrl+C, so it is opt-in.
467    pub fn create_process_group(mut self, create: bool) -> Self {
468        self.create_process_group = create;
469        self
470    }
471
472    /// Kill this child when the spawning process exits unexpectedly.
473    ///
474    /// Linux uses `PR_SET_PDEATHSIG(SIGTERM)` plus a pre-exec hard-exit race
475    /// guard when the parent changed before that signal could be armed.
476    /// Windows assigns the child to a
477    /// process-wide kill-on-close Job Object. macOS forks a kqueue supervisor
478    /// before exec and reports spawn success only after its owner and child
479    /// watches are registered.
480    pub fn kill_when_owner_dies(mut self, kill: bool) -> Self {
481        self.kill_when_owner_dies = kill;
482        self
483    }
484
485    /// Apply the host's existing niceness policy at child creation.
486    ///
487    /// On Unix this is the requested `setpriority(PRIO_PROCESS)` niceness.
488    /// Windows maps the established niceness bands to process creation
489    /// priority classes; it is deliberately a coarse host mapping rather
490    /// than a claim that numeric nice values are portable.
491    pub fn nice(mut self, nice: Option<i32>) -> Self {
492        self.nice = nice;
493        self
494    }
495
496    /// Select portable scheduling intent at native process creation.
497    pub fn priority(self, priority: ProcessPriority) -> Self {
498        self.nice(priority.nice_value())
499    }
500
501    /// Request portable scheduling intent where host policy permits it.
502    ///
503    /// The current host launch boundary applies this at creation; platforms
504    /// that reject the requested class retain their native error behavior.
505    pub fn priority_best_effort(self, priority: ProcessPriority) -> Self {
506        self.priority(priority)
507    }
508
509    /// Acquire a caller-owned permit around the native spawn attempt.
510    pub fn spawn_admission(mut self, admission: SpawnAdmission) -> Self {
511        self.admission = Some(admission);
512        self
513    }
514
515    /// Spawn using the canonical asynchronous platform operation.
516    pub async fn spawn(self) -> io::Result<PlatformChild> {
517        let mut command = Command::new(&self.program);
518        command.args(&self.args);
519        if let Some(current_dir) = self.current_dir.as_deref() {
520            command.current_dir(current_dir);
521        }
522        if self.clear_env {
523            command.env_clear();
524        }
525        for (key, value) in &self.env {
526            command.env(key, value);
527        }
528        command
529            .stdin(self.stdin.apply())
530            .stdout(self.stdout.apply())
531            .stderr(self.stderr.apply());
532        platform_imp::configure_command(
533            &mut command,
534            self.create_process_group,
535            self.kill_when_owner_dies,
536            self.nice,
537        )?;
538
539        let mut spawn = || command.spawn();
540        let child = match self.admission.as_ref() {
541            Some(admission) => admission.run(spawn)?,
542            None => spawn()?,
543        };
544        platform_imp::after_spawn(&child, self.kill_when_owner_dies)?;
545        Ok(PlatformChild::new(child, self.create_process_group))
546    }
547}
548
549/// Owned child handle returned by [`SpawnSpec::spawn`].
550#[cfg(feature = "async-process")]
551pub struct PlatformChild {
552    child: Child,
553    stdin: Option<ChildStdin>,
554    stdout: Option<ChildStdout>,
555    stderr: Option<ChildStderr>,
556    signal: PlatformEmergencySignal,
557}
558
559#[cfg(feature = "async-process")]
560impl PlatformChild {
561    fn new(mut child: Child, own_process_group: bool) -> Self {
562        let signal = PlatformEmergencySignal {
563            identity: async_child_identity(&child),
564            own_process_group,
565            // The legacy AsyncProcess actor historically retained only this
566            // numeric child-group leader on macOS. Keep it launch-bound for
567            // that API's compatibility path; sessions deliberately never use
568            // it because their control capability promises identity safety.
569            legacy_group_pid: child.id(),
570        };
571        Self {
572            stdin: child.stdin.take(),
573            stdout: child.stdout.take(),
574            stderr: child.stderr.take(),
575            child,
576            signal,
577        }
578    }
579
580    /// Return the operating-system process identifier, if available.
581    pub fn id(&self) -> Option<u32> {
582        self.child.id()
583    }
584
585    /// Wait for completion without capturing output.
586    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
587        self.child.wait().await
588    }
589
590    /// Terminate the child and wait for its exit.
591    pub async fn kill(&mut self) -> io::Result<()> {
592        self.child.kill().await
593    }
594
595    /// Capture piped stdout and stderr while waiting for the child.
596    pub async fn wait_with_output(self) -> io::Result<Output> {
597        let Self {
598            mut child,
599            stdin,
600            stdout,
601            stderr,
602            ..
603        } = self;
604        // Match Tokio's `Child::wait_with_output` contract: one-shot output
605        // closes an owned stdin pipe so a child waiting for EOF can finish.
606        drop(stdin);
607        let (status, stdout, stderr) = tokio::try_join!(
608            child.wait(),
609            read_owned_to_end(stdout),
610            read_owned_to_end(stderr),
611        )?;
612        Ok(Output {
613            status,
614            stdout,
615            stderr,
616        })
617    }
618
619    /// Write bytes to piped stdin and flush them.
620    pub async fn write_stdin(&mut self, bytes: &[u8]) -> io::Result<()> {
621        let stdin = self.stdin.as_mut().ok_or_else(stdin_not_piped)?;
622        stdin.write_all(bytes).await?;
623        stdin.flush().await
624    }
625
626    /// Close the piped stdin handle, delivering EOF to the child.
627    ///
628    /// This operation is idempotent. Closing an inherited or null stdin is
629    /// also a no-op because there is no owned pipe to close.
630    pub fn close_stdin(&mut self) {
631        drop(self.stdin.take());
632    }
633
634    /// Read all bytes from piped stdout without waiting for process exit.
635    pub async fn read_stdout_to_end(&mut self) -> io::Result<Vec<u8>> {
636        let stdout = self.stdout.as_mut().ok_or_else(stdout_not_piped)?;
637        let mut bytes = Vec::new();
638        stdout.read_to_end(&mut bytes).await?;
639        Ok(bytes)
640    }
641
642    /// Read all bytes from piped stderr without waiting for process exit.
643    pub async fn read_stderr_to_end(&mut self) -> io::Result<Vec<u8>> {
644        let stderr = self.stderr.as_mut().ok_or_else(stderr_not_piped)?;
645        let mut bytes = Vec::new();
646        stderr.read_to_end(&mut bytes).await?;
647        Ok(bytes)
648    }
649
650    /// Split this child into sealed actor capabilities.
651    ///
652    /// The lifecycle wait handle, emergency termination handle, input pipe,
653    /// and output readers are deliberately separate so the actor can keep
654    /// accepting control commands while an asynchronous exit wait is pending.
655    pub fn into_actor_parts(
656        self,
657    ) -> (
658        PlatformLifecycle,
659        PlatformEmergencySignal,
660        Option<PlatformStdin>,
661        Option<PlatformOutput>,
662        Option<PlatformOutput>,
663    ) {
664        (
665            PlatformLifecycle { child: self.child },
666            self.signal,
667            self.stdin.map(|stdin| PlatformStdin { stdin }),
668            self.stdout.map(PlatformOutput::stdout),
669            self.stderr.map(PlatformOutput::stderr),
670        )
671    }
672}
673
674/// Opaque exit-wait capability owned by a process actor.
675#[cfg(feature = "async-process")]
676pub struct PlatformLifecycle {
677    child: Child,
678}
679
680#[cfg(feature = "async-process")]
681impl PlatformLifecycle {
682    /// Wait asynchronously for the child to exit.
683    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
684        self.child.wait().await
685    }
686
687    /// Request direct-child termination through the still-owned child handle
688    /// without waiting for its reaping result.
689    ///
690    /// This is the identity-safe fallback when a host cannot provide a
691    /// separately usable launch-bound signal capability (for example a Linux
692    /// kernel without pidfds). The actor continues to own this lifecycle
693    /// handle and performs the eventual reap itself.
694    pub fn start_kill(&mut self) -> io::Result<()> {
695        self.child.start_kill()
696    }
697}
698
699/// Opaque, non-reap-capable emergency termination capability.
700///
701/// It can be used while the actor has a pending wait on
702/// [`PlatformLifecycle`], but it cannot observe or consume the exit result.
703#[cfg(feature = "async-process")]
704pub struct PlatformEmergencySignal {
705    identity: Option<AsyncChildIdentity>,
706    own_process_group: bool,
707    legacy_group_pid: Option<u32>,
708}
709
710#[cfg(feature = "async-process")]
711impl PlatformEmergencySignal {
712    /// Request immediate termination without waiting for process reaping.
713    pub fn kill(&self) -> io::Result<()> {
714        let Some(identity) = self.identity.as_ref() else {
715            return Err(signal_target_unavailable());
716        };
717        signal_async_child(identity)
718    }
719
720    /// Ask the child's whole process group to shut down gracefully.
721    ///
722    /// Returns `Ok(false)` when the child was not spawned with
723    /// [`SpawnSpec::create_process_group`]: there is no group to address, and
724    /// signalling anyway would hit the caller's own group on POSIX or the
725    /// caller's console on Windows. A missing or mismatched launch identity
726    /// instead reports an unavailable target; it never falls back to a
727    /// numeric group identifier that might have been reused.
728    pub fn terminate_group_soft(&self) -> io::Result<bool> {
729        if !self.own_process_group {
730            return Ok(false);
731        }
732        let Some(identity) = self.identity.as_ref() else {
733            return Err(signal_target_unavailable());
734        };
735        signal_async_child_group(identity).map(|()| true)
736    }
737
738    /// Legacy AsyncProcess-only graceful group termination.
739    ///
740    /// Most hosts retain an identity-safe asynchronous signal capability. On
741    /// macOS there is no pidfd-equivalent for the Tokio child path, while the
742    /// pre-session AsyncProcess contract historically sent SIGTERM to the
743    /// launch child's numeric process group. Preserve that established
744    /// best-effort behavior only for the legacy actor; sessions keep using
745    /// [`Self::terminate_group_soft`] and therefore remain identity-safe.
746    pub fn terminate_group_soft_legacy(&self) -> io::Result<bool> {
747        if !self.own_process_group {
748            return Ok(false);
749        }
750        if let Some(identity) = self.identity.as_ref() {
751            return signal_async_child_group(identity).map(|()| true);
752        }
753        let pid = self
754            .legacy_group_pid
755            .ok_or_else(signal_target_unavailable)?;
756        crate::platform::process::soft_terminate_process_group(pid).map(|()| true)
757    }
758
759    /// Return direct-child CPU time when this host can still verify the
760    /// launch identity. Unsupported hosts and already-reused identities are
761    /// reported as `None`, never as a PID-only best effort.
762    pub fn cpu_time(&self) -> io::Result<Option<std::time::Duration>> {
763        self.identity
764            .as_ref()
765            .map_or(Ok(None), async_child_cpu_time)
766    }
767}
768
769#[cfg(feature = "async-process")]
770fn signal_target_unavailable() -> io::Error {
771    io::Error::new(
772        io::ErrorKind::BrokenPipe,
773        "child process launch identity is no longer available",
774    )
775}
776
777/// Opaque piped stdin capability owned by a process actor.
778#[cfg(feature = "async-process")]
779pub struct PlatformStdin {
780    stdin: ChildStdin,
781}
782
783#[cfg(feature = "async-process")]
784impl PlatformStdin {
785    /// Write and flush bytes to the child stdin pipe.
786    pub async fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
787        self.stdin.write_all(bytes).await?;
788        self.stdin.flush().await
789    }
790}
791
792/// Opaque stdout or stderr reader owned by a process actor.
793#[cfg(feature = "async-process")]
794pub struct PlatformOutput {
795    reader: OutputReader,
796    read_pending: bool,
797}
798
799#[cfg(feature = "async-process")]
800enum OutputReader {
801    Stdout(ChildStdout),
802    Stderr(ChildStderr),
803}
804
805#[cfg(feature = "async-process")]
806impl PlatformOutput {
807    fn stdout(stdout: ChildStdout) -> Self {
808        Self {
809            reader: OutputReader::Stdout(stdout),
810            read_pending: false,
811        }
812    }
813
814    fn stderr(stderr: ChildStderr) -> Self {
815        Self {
816            reader: OutputReader::Stderr(stderr),
817            read_pending: false,
818        }
819    }
820
821    /// Drain this output endpoint to EOF without blocking a runtime worker.
822    pub async fn read_to_end(self) -> io::Result<Vec<u8>> {
823        match self.reader {
824            OutputReader::Stdout(stdout) => read_owned_to_end(Some(stdout)).await,
825            OutputReader::Stderr(stderr) => read_owned_to_end(Some(stderr)).await,
826        }
827    }
828
829    /// Read the next asynchronous chunk from this output endpoint.
830    ///
831    /// The caller owns the buffer and therefore controls the amount of data
832    /// retained at each read. EOF is reported as `Ok(0)`.
833    pub async fn read_chunk(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
834        self.read_pending = true;
835        let result = match &mut self.reader {
836            OutputReader::Stdout(stdout) => stdout.read(buffer).await,
837            OutputReader::Stderr(stderr) => stderr.read(buffer).await,
838        };
839        self.read_pending = false;
840        result
841    }
842
843    /// Abandon output and await destruction of this reader's pending I/O.
844    ///
845    /// This is not direct-child reaping or lossless EOF. The owning actor must
846    /// retain this future until completion: dropping it is not an acknowledgement
847    /// that platform I/O storage has been released. Cancellation requests alone
848    /// do not count as completion, and an unresponsive driver may keep it pending.
849    pub async fn shutdown(self) -> io::Result<()> {
850        match self.reader {
851            OutputReader::Stdout(stdout) => {
852                platform_imp::shutdown_output_reader(stdout, self.read_pending).await
853            }
854            OutputReader::Stderr(stderr) => {
855                platform_imp::shutdown_output_reader(stderr, self.read_pending).await
856            }
857        }
858    }
859}
860
861#[cfg(all(test, feature = "async-process"))]
862mod output_shutdown_tests {
863    use super::*;
864    use std::time::Duration;
865
866    #[test]
867    fn silent_output_fixture() {
868        if std::env::var_os("RUNNING_PROCESS_OUTPUT_SHUTDOWN_FIXTURE").is_some() {
869            std::thread::sleep(Duration::from_secs(30));
870        }
871    }
872
873    #[tokio::test]
874    async fn shutdown_finishes_a_cancelled_pending_read_before_child_exit() {
875        let child = SpawnSpec::new(std::env::current_exe().expect("test executable"))
876            .arg("--exact")
877            .arg("output_shutdown_tests::silent_output_fixture")
878            .env("RUNNING_PROCESS_OUTPUT_SHUTDOWN_FIXTURE", "1")
879            .stdin(StreamMode::Null)
880            .stdout(StreamMode::Piped)
881            .stderr(StreamMode::Null)
882            .spawn()
883            .await
884            .expect("spawn silent fixture");
885        let (mut lifecycle, _, _, stdout, _) = child.into_actor_parts();
886        let mut stdout = stdout.expect("stdout pipe");
887        // Drain the libtest header, then establish a genuinely pending read.
888        let mut bytes = [0; 1024];
889        loop {
890            match tokio::time::timeout(Duration::from_millis(20), stdout.read_chunk(&mut bytes))
891                .await
892            {
893                Ok(Ok(0)) => panic!("fixture exited before pending read"),
894                Ok(Ok(_)) => {}
895                Ok(Err(error)) => panic!("fixture read failed: {error}"),
896                Err(_) => break,
897            }
898        }
899        let shutdown = tokio::time::timeout(Duration::from_secs(2), stdout.shutdown()).await;
900        // Reap even if the assertion fails; a passing shutdown cannot depend
901        // on this kill, which occurs only after the acknowledgement deadline.
902        lifecycle.start_kill().expect("kill fixture");
903        lifecycle.wait().await.expect("reap fixture");
904        shutdown
905            .expect("pending read shutdown must finish")
906            .expect("output shutdown");
907    }
908}
909
910#[cfg(feature = "async-process")]
911fn stdin_not_piped() -> io::Error {
912    io::Error::new(io::ErrorKind::BrokenPipe, "child stdin is not piped")
913}
914
915#[cfg(feature = "async-process")]
916fn stdout_not_piped() -> io::Error {
917    io::Error::new(io::ErrorKind::BrokenPipe, "child stdout is not piped")
918}
919
920#[cfg(feature = "async-process")]
921fn stderr_not_piped() -> io::Error {
922    io::Error::new(io::ErrorKind::BrokenPipe, "child stderr is not piped")
923}
924
925#[cfg(feature = "async-process")]
926async fn read_owned_to_end<R>(reader: Option<R>) -> io::Result<Vec<u8>>
927where
928    R: AsyncRead + Unpin,
929{
930    let Some(mut reader) = reader else {
931        return Ok(Vec::new());
932    };
933    let mut bytes = Vec::new();
934    reader.read_to_end(&mut bytes).await?;
935    Ok(bytes)
936}
937
938/// Build a shell command using the host platform's supported shell.
939#[cfg(feature = "async-process")]
940pub fn shell_spec(command: impl AsRef<OsStr>) -> SpawnSpec {
941    platform_imp::shell_spec(command.as_ref())
942}
943
944#[cfg(all(test, feature = "async-process"))]
945mod tests {
946    use super::{shell_spec, SpawnSpec, StreamMode};
947
948    fn fixture_command() -> SpawnSpec {
949        #[cfg(windows)]
950        {
951            shell_spec("echo async-platform-internal")
952        }
953        #[cfg(not(windows))]
954        {
955            shell_spec("printf async-platform-internal")
956        }
957    }
958
959    #[tokio::test]
960    async fn blessed_spawn_captures_output_without_sync_wait() {
961        let output = fixture_command()
962            .stdout(StreamMode::Piped)
963            .stderr(StreamMode::Piped)
964            .spawn()
965            .await
966            .expect("spawn")
967            .wait_with_output()
968            .await
969            .expect("wait with output");
970
971        assert!(output.status.success());
972        let expected = if cfg!(windows) {
973            b"async-platform-internal\r\n".as_slice()
974        } else {
975            b"async-platform-internal".as_slice()
976        };
977        assert_eq!(output.stdout, expected);
978        assert!(output.stderr.is_empty());
979    }
980
981    #[tokio::test]
982    async fn blessed_spawn_reports_missing_program() {
983        let result = SpawnSpec::new("running-process-program-that-does-not-exist")
984            .spawn()
985            .await;
986        assert!(result.is_err());
987    }
988
989    #[tokio::test]
990    async fn one_shot_output_closes_owned_stdin() {
991        #[cfg(windows)]
992        let spec = shell_spec("more > nul & echo done");
993        #[cfg(not(windows))]
994        let spec = shell_spec("cat > /dev/null; printf done");
995
996        let output = tokio::time::timeout(
997            std::time::Duration::from_secs(2),
998            spec.stdin(StreamMode::Piped)
999                .stdout(StreamMode::Piped)
1000                .stderr(StreamMode::Piped)
1001                .spawn()
1002                .await
1003                .expect("spawn")
1004                .wait_with_output(),
1005        )
1006        .await
1007        .expect("stdin is closed for one-shot output")
1008        .expect("output succeeds");
1009
1010        let expected = if cfg!(windows) {
1011            b"done\r\n".as_slice()
1012        } else {
1013            b"done".as_slice()
1014        };
1015        assert_eq!(output.stdout, expected);
1016    }
1017}