running-process 4.10.13

Subprocess and PTY runtime for the running-process project
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
use std::collections::VecDeque;
use std::ffi::OsString;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant};

use thiserror::Error;

use running_process_platform_internal::platform::terminal as pty_platform;

/// Compatibility re-exports for downstream crates using the pre-boundary PTY API.
///
/// New code should use this module's [`PtyMaster`], [`PtyChild`], and [`PtySize`]
/// facade-owned types. The concrete portable-pty escape hatch remains available
/// through the next major release so existing imports keep compiling.
#[deprecated(
    note = "use running_process::pty facade-owned types; portable-pty compatibility will be removed in 5.0"
)]
pub mod reexports {
    /// Re-export of the historical `portable_pty` dependency.
    pub use running_process_platform_internal::portable_pty_compat as portable_pty;
}

/// Native terminal input capture and translation helpers.
pub mod terminal_input;

/// Reports whether the process-wide ConPTY API table resolved to the
/// system `kernel32.dll` or to a sidecar `conpty.dll`. See #443.
///
/// Integration tests gate Win10-with-sidecar coverage on this — the
/// byte-exact passthrough assertions can only hold when a sidecar is
/// loaded on Win10, since the system `kernel32!CreatePseudoConsole`
/// on Win10 < build 22000 silently ignores `PSEUDOCONSOLE_PASSTHROUGH_MODE`.
pub use running_process_platform_internal::platform::terminal::{
    current_backend_kind, ConPtyBackendKind,
};

// #150: backend abstraction so native_pty_process.rs calls a single
// Backend::openpty() regardless of platform. Made `pub` in 4.0.1 so
// downstream consumers (e.g. clud's SIGWINCH relay) can call
// `PtyMaster::resize` / `get_size` through `NativePtyHandles.master`.
/// Cross-platform PTY backend traits and platform-selected implementations.
pub mod backend;
/// Re-exported PTY backend handles and size type.
pub use backend::{PtyChild, PtyMaster, PtySize};

/// Build an argv for the selected host shell.
pub fn platform_shell_argv(command: &str) -> Vec<String> {
    pty_platform::shell_argv(command)
}

/// Whether this host can observe child exit before closing the PTY master.
pub fn wait_before_close_supported() -> bool {
    pty_platform::wait_before_close_supported()
}

/// Build a `portable_pty::CommandBuilder` from an argv vector.
#[deprecated(
    note = "use NativePtyProcess or a facade-owned PTY backend; this helper will be removed in 5.0"
)]
pub fn command_builder_from_argv(
    argv: &[String],
) -> running_process_platform_internal::portable_pty_compat::CommandBuilder {
    use running_process_platform_internal::portable_pty_compat::CommandBuilder;

    let mut command = CommandBuilder::new(&argv[0]);
    if argv.len() > 1 {
        command.args(
            argv[1..]
                .iter()
                .map(OsString::from)
                .collect::<Vec<OsString>>(),
        );
    }
    command
}

/// Convert a `portable_pty` exit status into this crate's signed exit-code convention.
#[deprecated(
    note = "use PtyChild::wait; direct portable-pty status conversion will be removed in 5.0"
)]
pub fn portable_exit_code(
    status: running_process_platform_internal::portable_pty_compat::ExitStatus,
) -> i32 {
    if let Some(signal) = status.signal() {
        let signal = signal.to_ascii_lowercase();
        if signal.contains("interrupt") {
            return -2;
        }
        if signal.contains("terminated") {
            return -15;
        }
        if signal.contains("killed") {
            return -9;
        }
    }
    status.exit_code() as i32
}

mod native_pty_process;
/// Re-exported native PTY process and interactive session types.
pub use native_pty_process::{
    InteractivePtyOptions, InteractivePtyPumpResult, InteractivePtySession, NativePtyProcess,
};

/// Async PTY facade using the bounded synchronous platform island.
#[cfg(feature = "async-process")]
pub mod async_pty;
#[cfg(feature = "async-process")]
pub use async_pty::{AsyncPtyProcess, IdleWaitOutcome};

/// Errors returned by pseudo-terminal process operations.
#[derive(Debug, Error)]
pub enum PtyError {
    /// The pseudo-terminal process has already been started.
    #[error("pseudo-terminal process already started")]
    AlreadyStarted,
    /// The pseudo-terminal process is not currently running.
    #[error("pseudo-terminal process is not running")]
    NotRunning,
    /// The pseudo-terminal operation exceeded its timeout.
    #[error("pseudo-terminal timed out")]
    Timeout,
    /// An underlying I/O operation failed.
    #[error("pseudo-terminal I/O error: {0}")]
    Io(
        /// The underlying I/O error.
        #[from]
        std::io::Error,
    ),
    /// Spawning the pseudo-terminal process failed.
    #[error("pseudo-terminal spawn failed: {0}")]
    Spawn(
        /// Backend-provided spawn failure details.
        String,
    ),
    /// A pseudo-terminal operation failed for another reason.
    #[error("pseudo-terminal error: {0}")]
    Other(
        /// Human-readable error details.
        String,
    ),
}

/// Return whether a process-control error can be ignored during cleanup.
pub fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
    pty_platform::is_ignorable_process_control_error(err)
}

/// Buffered output and close state for a PTY reader thread.
pub struct PtyReadState {
    /// Output chunks read from the PTY master.
    pub chunks: VecDeque<Vec<u8>>,
    /// Whether the PTY reader has reached EOF or stopped.
    pub closed: bool,
}

/// Shared reader state paired with a condition variable for waiters.
pub struct PtyReadShared {
    /// Protected reader buffer and close state.
    pub state: Mutex<PtyReadState>,
    /// Notifies waiters when output arrives or the reader closes.
    pub condvar: Condvar,
}

/// Platform-neutral handles for a running native PTY child.
/// Independently-lockable PTY input writer (issue #590, cluster D). Kept
/// separate from the `handles` mutex so a blocking write never holds that
/// lock — see the field docs on [`NativePtyHandles::writer`].
pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;

pub struct NativePtyHandles {
    // #150: master/child previously stored concrete backend types.
    // Refactored to use the cross-platform PtyMaster / PtyChild
    // traits so the Windows path goes through `conpty_passthrough`
    // (with PSEUDOCONSOLE_PASSTHROUGH_MODE) instead of portable-pty.
    /// Master side of the PTY, used for resize and size queries.
    pub master: Box<dyn crate::pty::backend::PtyMaster>,
    /// Writer connected to the PTY master input stream.
    ///
    /// Held in its own `Arc<Mutex<…>>` (issue #590, cluster D) so a
    /// blocking `write_all` on a full input pipe does NOT hold the outer
    /// `handles` mutex. Otherwise `close()`/`kill()`/`poll()` — which all
    /// lock `handles` — would deadlock behind an input write the child has
    /// stopped consuming.
    pub writer: SharedPtyWriter,
    /// Spawned child process attached to the PTY slave.
    pub child: Box<dyn crate::pty::backend::PtyChild>,
    /// Host-owned process-tree containment guard.
    pub process_guard: pty_platform::PtyProcessGuard,
}

/// Shared mutable state for idle detection waits.
pub struct IdleMonitorState {
    /// Last time input or qualifying output reset the idle timer.
    pub last_reset_at: Instant,
    /// Observed child return code, when the process has exited.
    pub returncode: Option<i32>,
    /// Whether the recorded exit was caused by an interrupt request.
    pub interrupted: bool,
}

/// Core idle detection logic, shareable across threads via Arc.
/// The reader thread calls `record_output` directly.
pub struct IdleDetectorCore {
    /// Minimum idle duration before the detector reports an idle timeout.
    pub timeout_seconds: f64,
    /// Additional quiet window required before reporting idle.
    pub stability_window_seconds: f64,
    /// Poll interval used while waiting for idle or exit.
    pub sample_interval_seconds: f64,
    /// Whether PTY input resets the idle timer.
    pub reset_on_input: bool,
    /// Whether PTY output resets the idle timer.
    pub reset_on_output: bool,
    /// Whether ANSI/control churn without visible bytes counts as output.
    pub count_control_churn_as_output: bool,
    /// Runtime switch that enables or disables idle timeout detection.
    pub enabled: Arc<AtomicBool>,
    /// Protected idle timing and exit state.
    pub state: Mutex<IdleMonitorState>,
    /// Notifies idle waiters when activity, exit, or enablement changes.
    pub condvar: Condvar,
}

impl IdleDetectorCore {
    /// Record input activity and reset the idle timer when configured.
    pub fn record_input(&self, byte_count: usize) {
        if !self.reset_on_input || byte_count == 0 {
            return;
        }
        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
        guard.last_reset_at = Instant::now();
        self.condvar.notify_all();
    }

    /// Record output activity and reset the idle timer when configured.
    pub fn record_output(&self, data: &[u8]) {
        if !self.reset_on_output || data.is_empty() {
            return;
        }
        let control_bytes = control_churn_bytes(data);
        let visible_output_bytes = data.len().saturating_sub(control_bytes);
        let active_output =
            visible_output_bytes > 0 || (self.count_control_churn_as_output && control_bytes > 0);
        if !active_output {
            return;
        }
        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
        guard.last_reset_at = Instant::now();
        self.condvar.notify_all();
    }

    /// Record child process exit information and wake idle waiters.
    pub fn mark_exit(&self, returncode: i32, interrupted: bool) {
        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
        guard.returncode = Some(returncode);
        guard.interrupted = interrupted;
        self.condvar.notify_all();
    }

    /// Return whether idle timeout detection is currently enabled.
    pub fn enabled(&self) -> bool {
        self.enabled.load(Ordering::Acquire)
    }

    /// Enable or disable idle timeout detection.
    pub fn set_enabled(&self, enabled: bool) {
        let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel);
        if enabled && !was_enabled {
            let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
            guard.last_reset_at = Instant::now();
        }
        self.condvar.notify_all();
    }

    /// Wait until the child exits, the idle threshold is reached, or the timeout expires.
    pub fn wait(&self, timeout: Option<f64>) -> (bool, String, f64, Option<i32>) {
        let started = Instant::now();
        let overall_timeout = timeout.map(Duration::from_secs_f64);
        let min_idle = self.timeout_seconds.max(self.stability_window_seconds);
        let sample_interval = Duration::from_secs_f64(self.sample_interval_seconds.max(0.001));

        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
        loop {
            let now = Instant::now();
            let idle_for = now.duration_since(guard.last_reset_at).as_secs_f64();

            if let Some(returncode) = guard.returncode {
                let reason = if guard.interrupted {
                    "interrupt"
                } else {
                    "process_exit"
                };
                return (false, reason.to_string(), idle_for, Some(returncode));
            }

            let enabled = self.enabled.load(Ordering::Acquire);
            if enabled && idle_for >= min_idle {
                return (true, "idle_timeout".to_string(), idle_for, None);
            }

            if let Some(limit) = overall_timeout {
                if now.duration_since(started) >= limit {
                    return (false, "timeout".to_string(), idle_for, None);
                }
            }

            let idle_remaining = if enabled {
                (min_idle - idle_for).max(0.0)
            } else {
                sample_interval.as_secs_f64()
            };
            let mut wait_for =
                sample_interval.min(Duration::from_secs_f64(idle_remaining.max(0.001)));
            if let Some(limit) = overall_timeout {
                let elapsed = now.duration_since(started);
                if elapsed < limit {
                    let remaining = limit - elapsed;
                    wait_for = wait_for.min(remaining);
                }
            }
            let result = self
                .condvar
                .wait_timeout(guard, wait_for)
                .expect("idle monitor mutex poisoned");
            guard = result.0;
        }
    }
}

// ── Helper functions ──

/// Count ANSI/control bytes that should not be treated as visible output.
pub fn control_churn_bytes(data: &[u8]) -> usize {
    let mut total = 0;
    let mut index = 0;
    while index < data.len() {
        let byte = data[index];
        if byte == 0x1B {
            let start = index;
            index += 1;
            if index < data.len() && data[index] == b'[' {
                index += 1;
                while index < data.len() {
                    let current = data[index];
                    index += 1;
                    if (0x40..=0x7E).contains(&current) {
                        break;
                    }
                }
            }
            total += index - start;
            continue;
        }
        if matches!(byte, 0x08 | 0x0D | 0x7F) {
            total += 1;
        }
        index += 1;
    }
    total
}

/// Spawn the background reader that drains PTY output into shared state.
#[inline(never)]
pub fn spawn_pty_reader(
    mut reader: Box<dyn Read + Send>,
    shared: Arc<PtyReadShared>,
    echo: Arc<AtomicBool>,
    idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
    output_bytes_total: Arc<AtomicUsize>,
    control_churn_bytes_total: Arc<AtomicUsize>,
) {
    crate::rp_rust_debug_scope!("running_process::spawn_pty_reader");
    let idle_detector_snapshot = idle_detector
        .lock()
        .expect("idle detector mutex poisoned")
        .clone();
    let mut chunk = vec![0_u8; 65536];
    loop {
        match reader.read(&mut chunk) {
            Ok(0) => break,
            Ok(n) => {
                let data = &chunk[..n];

                let churn = control_churn_bytes(data);
                let visible = data.len().saturating_sub(churn);
                output_bytes_total.fetch_add(visible, Ordering::Relaxed);
                control_churn_bytes_total.fetch_add(churn, Ordering::Relaxed);

                if echo.load(Ordering::Relaxed) {
                    let _ = std::io::stdout().write_all(data);
                    let _ = std::io::stdout().flush();
                }

                if let Some(ref detector) = idle_detector_snapshot {
                    detector.record_output(data);
                }

                let mut guard = shared.state.lock().expect("pty read mutex poisoned");
                guard.chunks.push_back(data.to_vec());
                shared.condvar.notify_all();
            }
            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
            Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
                // #199: intentional — back-off on a non-blocking PTY
                // master read that returned WouldBlock. There's no
                // POSIX "wait for fd readable" that's portable
                // across the OwnedFd / Windows OwnedHandle paths
                // used here.
                thread::sleep(Duration::from_millis(10));
                continue;
            }
            Err(_) => break,
        }
    }
    let mut guard = shared.state.lock().expect("pty read mutex poisoned");
    guard.closed = true;
    shared.condvar.notify_all();
}

/// Return whether input bytes contain a carriage return or newline.
pub fn input_contains_newline(data: &[u8]) -> bool {
    data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
}

/// Relay bytes from the selected host terminal into the active PTY until stopped or exited.
pub(super) struct TerminalInputRelayState {
    pub handles: Arc<Mutex<Option<NativePtyHandles>>>,
    pub returncode: Arc<Mutex<Option<i32>>>,
    pub input_bytes_total: Arc<AtomicUsize>,
    pub newline_events_total: Arc<AtomicUsize>,
    pub submit_events_total: Arc<AtomicUsize>,
    pub stop: Arc<AtomicBool>,
    pub active: Arc<AtomicBool>,
}

#[inline(never)]
pub(super) fn terminal_input_relay_worker(
    input: pty_platform::TerminalInputSession,
    state: TerminalInputRelayState,
) {
    loop {
        if state.stop.load(Ordering::Acquire) {
            break;
        }
        match poll_pty_process(&state.handles, &state.returncode) {
            Ok(Some(_)) => break,
            Ok(None) => {}
            Err(_) => break,
        }

        let chunk = match input.read_chunk(Duration::from_millis(50)) {
            Ok(Some(chunk)) => chunk,
            Ok(None) => continue,
            Err(_) => break,
        };

        record_pty_input_metrics(
            &state.input_bytes_total,
            &state.newline_events_total,
            &state.submit_events_total,
            &chunk.data,
            chunk.submit,
        );
        if write_pty_input(&state.handles, &chunk.data).is_err() {
            break;
        }
    }

    state.active.store(false, Ordering::Release);
}

/// Record PTY input byte, newline, and submit counters for one input chunk.
pub fn record_pty_input_metrics(
    input_bytes_total: &Arc<AtomicUsize>,
    newline_events_total: &Arc<AtomicUsize>,
    submit_events_total: &Arc<AtomicUsize>,
    data: &[u8],
    submit: bool,
) {
    input_bytes_total.fetch_add(data.len(), Ordering::AcqRel);
    if input_contains_newline(data) {
        newline_events_total.fetch_add(1, Ordering::AcqRel);
    }
    if submit {
        submit_events_total.fetch_add(1, Ordering::AcqRel);
    }
}

/// Store the PTY child return code in shared process state.
pub fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
    *returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
}

/// Poll the PTY child process and persist its return code after exit.
pub fn poll_pty_process(
    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
    returncode: &Arc<Mutex<Option<i32>>>,
) -> Result<Option<i32>, std::io::Error> {
    let mut guard = handles.lock().expect("pty handles mutex poisoned");
    let Some(handles) = guard.as_mut() else {
        return Ok(*returncode.lock().expect("pty returncode mutex poisoned"));
    };
    let status = handles.child.try_wait()?;
    // #150: try_wait now returns Option<u32> (from PtyChild trait)
    // The platform-owned child status is an unsigned exit code. Cast for storage.
    let code = status.map(|c| c as i32);
    if let Some(code) = code {
        store_pty_returncode(returncode, code);
        return Ok(Some(code));
    }
    Ok(None)
}

/// Write input bytes to the running PTY after platform-specific translation.
pub fn write_pty_input(
    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
    data: &[u8],
) -> Result<(), std::io::Error> {
    // Clone the writer handle out from under the `handles` lock, then
    // release `handles` BEFORE the blocking write (issue #590, cluster D).
    // The PTY input pipe fills when the child stops reading stdin; a
    // `write_all` that blocked while holding `handles` would deadlock every
    // teardown/poll path that also locks `handles`.
    let writer = {
        let guard = handles.lock().expect("pty handles mutex poisoned");
        let handles = guard.as_ref().ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::NotConnected,
                "Pseudo-terminal process is not running",
            )
        })?;
        Arc::clone(&handles.writer)
    };
    let payload = pty_platform::input_payload(data);
    let mut writer = writer.lock().expect("pty writer mutex poisoned");
    writer.write_all(&payload)?;
    writer.flush()
}

/// Translate newline bytes into the Windows PTY input payload format.
pub fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
    pty_platform::input_payload(data)
}

/// Compatibility name for the host-owned PTY process-tree guard.
pub type WindowsJobHandle = pty_platform::PtyProcessGuard;

/// Information about a child process found via Toolhelp snapshot.
pub use pty_platform::ChildProcessInfo;

/// Find all direct child processes of a given parent PID using the Windows Toolhelp API.
/// Returns PID and process name for each child.
pub fn find_child_processes(parent_pid: u32) -> Vec<ChildProcessInfo> {
    pty_platform::find_child_processes(parent_pid)
}

/// A conhost.exe process whose parent is no longer alive — likely an orphan
/// from a dead ConPTY session.
pub use pty_platform::OrphanConhostInfo;

/// Scan all conhost.exe processes on the system and return those whose parent
/// process is no longer alive. These are likely orphans from dead ConPTY sessions.
///
/// Uses `CreateToolhelp32Snapshot` for a point-in-time snapshot — no sysinfo
/// dependency, so it's lightweight and can be called frequently.
pub fn find_orphan_conhosts() -> Vec<OrphanConhostInfo> {
    pty_platform::find_orphan_conhosts()
}

#[cfg(test)]
mod tests {
    use super::native_pty_process::resolved_spawn_cwd;

    #[test]
    fn resolved_spawn_cwd_preserves_explicit_value() {
        assert_eq!(
            resolved_spawn_cwd(Some("C:\\temp\\explicit")),
            Some("C:\\temp\\explicit".to_string())
        );
    }

    #[test]
    fn resolved_spawn_cwd_defaults_to_current_dir_when_unset() {
        let expected = std::env::current_dir()
            .ok()
            .map(|cwd| cwd.to_string_lossy().to_string());
        assert_eq!(resolved_spawn_cwd(None), expected);
    }
}

#[cfg(test)]
#[path = "../tests/pty_core_coverage.rs"]
mod coverage_tests;