Skip to main content

_native/
lib.rs

1use std::collections::{HashMap, VecDeque};
2use std::ffi::OsString;
3#[cfg(windows)]
4use std::fs;
5#[cfg(windows)]
6use std::fs::OpenOptions;
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10use std::sync::Arc;
11use std::sync::{Condvar, Mutex, OnceLock};
12use std::thread;
13use std::time::Duration;
14use std::time::Instant;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
18use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError};
19use pyo3::prelude::*;
20use pyo3::types::{PyBytes, PyDict, PyList, PyString};
21use regex::Regex;
22use running_process_core::{
23    find_processes_by_originator, render_rust_debug_traces, CommandSpec, ContainedChild,
24    ContainedProcessGroup, Containment, NativeProcess, OriginatorProcessInfo, ProcessConfig,
25    ProcessError, ReadStatus, StderrMode, StdinMode, StreamEvent, StreamKind,
26};
27#[cfg(unix)]
28use running_process_core::{
29    unix_set_priority, unix_signal_process, unix_signal_process_group, UnixSignal,
30};
31use sysinfo::{Pid, ProcessRefreshKind, Signal, System, UpdateKind};
32
33mod daemon_client;
34#[cfg(unix)]
35mod pty_posix;
36#[cfg(windows)]
37mod pty_windows;
38mod public_symbols;
39
40#[cfg(unix)]
41use pty_posix as pty_platform;
42
43#[cfg(windows)]
44const NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV: &str =
45    "RUNNING_PROCESS_NATIVE_TERMINAL_INPUT_TRACE_PATH";
46
47fn to_py_err(err: impl std::fmt::Display) -> PyErr {
48    PyRuntimeError::new_err(err.to_string())
49}
50
51fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
52    if matches!(
53        err.kind(),
54        std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput
55    ) {
56        return true;
57    }
58    #[cfg(unix)]
59    if err.raw_os_error() == Some(libc::ESRCH) {
60        return true;
61    }
62    false
63}
64
65fn process_err_to_py(err: ProcessError) -> PyErr {
66    match err {
67        ProcessError::Timeout => PyTimeoutError::new_err("process timed out"),
68        other => to_py_err(other),
69    }
70}
71
72fn system_pid(pid: u32) -> Pid {
73    Pid::from_u32(pid)
74}
75
76fn descendant_pids(system: &System, pid: Pid) -> Vec<Pid> {
77    let mut descendants = Vec::new();
78    let mut stack = vec![pid];
79    while let Some(current) = stack.pop() {
80        for (child_pid, process) in system.processes() {
81            if process.parent() == Some(current) {
82                descendants.push(*child_pid);
83                stack.push(*child_pid);
84            }
85        }
86    }
87    descendants
88}
89
90#[derive(Clone)]
91struct ActiveProcessRecord {
92    pid: u32,
93    kind: String,
94    command: String,
95    cwd: Option<String>,
96    started_at: f64,
97}
98
99type TrackedProcessEntry = (u32, f64, String, String, Option<String>);
100type ActiveProcessEntry = (u32, String, String, Option<String>, f64);
101type ExpectDetails = (String, usize, usize, Vec<String>);
102type ExpectResult = (
103    String,
104    String,
105    Option<String>,
106    Option<usize>,
107    Option<usize>,
108    Vec<String>,
109);
110
111fn active_process_registry() -> &'static Mutex<HashMap<u32, ActiveProcessRecord>> {
112    static ACTIVE_PROCESSES: OnceLock<Mutex<HashMap<u32, ActiveProcessRecord>>> = OnceLock::new();
113    ACTIVE_PROCESSES.get_or_init(|| Mutex::new(HashMap::new()))
114}
115
116fn unix_now_seconds() -> f64 {
117    SystemTime::now()
118        .duration_since(UNIX_EPOCH)
119        .map(|duration| duration.as_secs_f64())
120        .unwrap_or(0.0)
121}
122
123#[cfg(windows)]
124fn native_terminal_input_trace_target() -> Option<String> {
125    std::env::var(NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV)
126        .ok()
127        .map(|value| value.trim().to_string())
128        .filter(|value| !value.is_empty())
129}
130
131#[cfg(windows)]
132fn append_native_terminal_input_trace_line(line: &str) {
133    let Some(target) = native_terminal_input_trace_target() else {
134        return;
135    };
136    if target == "-" {
137        eprintln!("{line}");
138        return;
139    }
140    let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&target) else {
141        return;
142    };
143    let _ = writeln!(file, "{line}");
144}
145
146#[cfg(windows)]
147fn format_terminal_input_bytes(data: &[u8]) -> String {
148    if data.is_empty() {
149        return "[]".to_string();
150    }
151    let parts: Vec<String> = data.iter().map(|byte| format!("{byte:02x}")).collect();
152    format!("[{}]", parts.join(" "))
153}
154
155fn register_active_process(
156    pid: u32,
157    kind: &str,
158    command: &str,
159    cwd: Option<String>,
160    started_at: f64,
161) {
162    let mut registry = active_process_registry()
163        .lock()
164        .expect("active process registry mutex poisoned");
165    registry.insert(
166        pid,
167        ActiveProcessRecord {
168            pid,
169            kind: kind.to_string(),
170            command: command.to_string(),
171            cwd: cwd.clone(),
172            started_at,
173        },
174    );
175    drop(registry); // release lock before IPC
176
177    // Fire-and-forget daemon notification.
178    daemon_client::daemon_register(pid, started_at, kind, command, cwd.as_deref());
179}
180
181fn unregister_active_process(pid: u32) {
182    let mut registry = active_process_registry()
183        .lock()
184        .expect("active process registry mutex poisoned");
185    registry.remove(&pid);
186    drop(registry); // release lock before IPC
187
188    // Fire-and-forget daemon notification.
189    daemon_client::daemon_unregister(pid);
190}
191
192fn process_created_at(pid: u32) -> Option<f64> {
193    let pid = system_pid(pid);
194    let mut system = System::new();
195    system.refresh_process_specifics(
196        pid,
197        ProcessRefreshKind::new()
198            .with_cpu()
199            .with_disk_usage()
200            .with_memory()
201            .with_exe(UpdateKind::Never),
202    );
203    system
204        .process(pid)
205        .map(|process| process.start_time() as f64)
206}
207
208fn same_process_identity(pid: u32, created_at: f64, tolerance_seconds: f64) -> bool {
209    let Some(actual) = process_created_at(pid) else {
210        return false;
211    };
212    (actual - created_at).abs() <= tolerance_seconds
213}
214
215fn tracked_process_db_path() -> PyResult<PathBuf> {
216    if let Ok(value) = std::env::var("RUNNING_PROCESS_PID_DB") {
217        let trimmed = value.trim();
218        if !trimmed.is_empty() {
219            return Ok(PathBuf::from(trimmed));
220        }
221    }
222
223    #[cfg(windows)]
224    let base_dir = std::env::var_os("LOCALAPPDATA")
225        .map(PathBuf::from)
226        .unwrap_or_else(std::env::temp_dir);
227
228    #[cfg(not(windows))]
229    let base_dir = std::env::var_os("XDG_STATE_HOME")
230        .map(PathBuf::from)
231        .or_else(|| {
232            std::env::var_os("HOME").map(|home| {
233                let mut path = PathBuf::from(home);
234                path.push(".local");
235                path.push("state");
236                path
237            })
238        })
239        .unwrap_or_else(std::env::temp_dir);
240
241    Ok(base_dir
242        .join("running-process")
243        .join("tracked-pids.sqlite3"))
244}
245
246#[pyfunction]
247fn tracked_pid_db_path_py() -> PyResult<String> {
248    Ok(tracked_process_db_path()?.to_string_lossy().into_owned())
249}
250
251#[pyfunction]
252#[pyo3(signature = (pid, created_at, kind, command, cwd=None))]
253fn track_process_pid(
254    pid: u32,
255    created_at: f64,
256    kind: &str,
257    command: &str,
258    cwd: Option<String>,
259) -> PyResult<()> {
260    let _ = created_at;
261    register_active_process(pid, kind, command, cwd, unix_now_seconds());
262    Ok(())
263}
264
265#[pyfunction]
266#[pyo3(signature = (pid, kind, command, cwd=None))]
267fn native_register_process(
268    pid: u32,
269    kind: &str,
270    command: &str,
271    cwd: Option<String>,
272) -> PyResult<()> {
273    register_active_process(pid, kind, command, cwd, unix_now_seconds());
274    Ok(())
275}
276
277#[pyfunction]
278fn untrack_process_pid(pid: u32) -> PyResult<()> {
279    unregister_active_process(pid);
280    Ok(())
281}
282
283#[pyfunction]
284fn native_unregister_process(pid: u32) -> PyResult<()> {
285    unregister_active_process(pid);
286    Ok(())
287}
288
289#[pyfunction]
290fn list_tracked_processes() -> PyResult<Vec<TrackedProcessEntry>> {
291    let registry = active_process_registry()
292        .lock()
293        .expect("active process registry mutex poisoned");
294    let mut entries: Vec<_> = registry
295        .values()
296        .map(|entry| {
297            (
298                entry.pid,
299                process_created_at(entry.pid).unwrap_or(entry.started_at),
300                entry.kind.clone(),
301                entry.command.clone(),
302                entry.cwd.clone(),
303            )
304        })
305        .collect();
306    entries.sort_by(|left, right| {
307        left.1
308            .partial_cmp(&right.1)
309            .unwrap_or(std::cmp::Ordering::Equal)
310            .then_with(|| left.0.cmp(&right.0))
311    });
312    Ok(entries)
313}
314
315fn kill_process_tree_impl(pid: u32, timeout_seconds: f64) {
316    let mut system = System::new_all();
317    let pid = system_pid(pid);
318    let Some(_) = system.process(pid) else {
319        return;
320    };
321
322    let mut kill_order = descendant_pids(&system, pid);
323    kill_order.reverse();
324    kill_order.push(pid);
325
326    for target in &kill_order {
327        if let Some(process) = system.process(*target) {
328            if !process.kill_with(Signal::Kill).unwrap_or(false) {
329                process.kill();
330            }
331        }
332    }
333
334    let deadline = Instant::now()
335        .checked_add(Duration::from_secs_f64(timeout_seconds.max(0.0)))
336        .unwrap_or_else(Instant::now);
337    loop {
338        system.refresh_processes();
339        if kill_order
340            .iter()
341            .all(|target| system.process(*target).is_none())
342        {
343            break;
344        }
345        if Instant::now() >= deadline {
346            break;
347        }
348        thread::sleep(Duration::from_millis(25));
349    }
350}
351
352#[cfg(windows)]
353fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
354    let mut translated = Vec::with_capacity(data.len());
355    let mut index = 0usize;
356    while index < data.len() {
357        let current = data[index];
358        if current == b'\r' {
359            translated.push(current);
360            if index + 1 < data.len() && data[index + 1] == b'\n' {
361                translated.push(b'\n');
362                index += 2;
363                continue;
364            }
365            index += 1;
366            continue;
367        }
368        if current == b'\n' {
369            translated.push(b'\r');
370            index += 1;
371            continue;
372        }
373        translated.push(current);
374        index += 1;
375    }
376    translated
377}
378
379#[cfg(windows)]
380fn native_terminal_input_mode(original_mode: u32) -> u32 {
381    use winapi::um::wincon::{
382        ENABLE_ECHO_INPUT, ENABLE_EXTENDED_FLAGS, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
383        ENABLE_QUICK_EDIT_MODE, ENABLE_WINDOW_INPUT,
384    };
385
386    (original_mode | ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT)
387        & !(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE)
388}
389
390#[cfg(windows)]
391fn terminal_input_modifier_parameter(shift: bool, alt: bool, ctrl: bool) -> Option<u8> {
392    let value = 1 + u8::from(shift) + (u8::from(alt) * 2) + (u8::from(ctrl) * 4);
393    (value > 1).then_some(value)
394}
395
396#[cfg(windows)]
397fn repeat_terminal_input_bytes(chunk: &[u8], repeat_count: u16) -> Vec<u8> {
398    let repeat = usize::from(repeat_count.max(1));
399    let mut output = Vec::with_capacity(chunk.len() * repeat);
400    for _ in 0..repeat {
401        output.extend_from_slice(chunk);
402    }
403    output
404}
405
406#[cfg(windows)]
407fn repeated_modified_sequence(base: &[u8], modifier: Option<u8>, repeat_count: u16) -> Vec<u8> {
408    if let Some(value) = modifier {
409        let base_text = std::str::from_utf8(base).expect("VT sequence literal must be utf-8");
410        let body = base_text
411            .strip_prefix("\x1b[")
412            .expect("VT sequence literal must start with CSI");
413        let sequence = format!("\x1b[1;{value}{body}");
414        repeat_terminal_input_bytes(sequence.as_bytes(), repeat_count)
415    } else {
416        repeat_terminal_input_bytes(base, repeat_count)
417    }
418}
419
420#[cfg(windows)]
421fn repeated_tilde_sequence(number: u8, modifier: Option<u8>, repeat_count: u16) -> Vec<u8> {
422    if let Some(value) = modifier {
423        let sequence = format!("\x1b[{number};{value}~");
424        repeat_terminal_input_bytes(sequence.as_bytes(), repeat_count)
425    } else {
426        let sequence = format!("\x1b[{number}~");
427        repeat_terminal_input_bytes(sequence.as_bytes(), repeat_count)
428    }
429}
430
431#[cfg(windows)]
432fn control_character_for_unicode(unicode: u16) -> Option<u8> {
433    let upper = char::from_u32(u32::from(unicode))?.to_ascii_uppercase();
434    match upper {
435        '@' | ' ' => Some(0x00),
436        'A'..='Z' => Some((upper as u8) - b'@'),
437        '[' => Some(0x1B),
438        '\\' => Some(0x1C),
439        ']' => Some(0x1D),
440        '^' => Some(0x1E),
441        '_' => Some(0x1F),
442        _ => None,
443    }
444}
445
446#[cfg(windows)]
447fn trace_translated_console_key_event(
448    record: &winapi::um::wincontypes::KEY_EVENT_RECORD,
449    event: TerminalInputEventRecord,
450) -> TerminalInputEventRecord {
451    append_native_terminal_input_trace_line(&format!(
452        "[{:.6}] native_terminal_input raw bKeyDown={} vk={:#06x} scan={:#06x} unicode={:#06x} control={:#010x} repeat={} translated bytes={} submit={} shift={} ctrl={} alt={}",
453        unix_now_seconds(),
454        record.bKeyDown,
455        record.wVirtualKeyCode,
456        record.wVirtualScanCode,
457        unsafe { *record.uChar.UnicodeChar() },
458        record.dwControlKeyState,
459        record.wRepeatCount.max(1),
460        format_terminal_input_bytes(&event.data),
461        event.submit,
462        event.shift,
463        event.ctrl,
464        event.alt,
465    ));
466    event
467}
468
469#[cfg(windows)]
470fn translate_console_key_event(
471    record: &winapi::um::wincontypes::KEY_EVENT_RECORD,
472) -> Option<TerminalInputEventRecord> {
473    use winapi::um::wincontypes::{
474        LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SHIFT_PRESSED,
475    };
476    use winapi::um::winuser::{
477        VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_HOME, VK_INSERT, VK_LEFT, VK_NEXT,
478        VK_PRIOR, VK_RETURN, VK_RIGHT, VK_TAB, VK_UP,
479    };
480
481    if record.bKeyDown == 0 {
482        append_native_terminal_input_trace_line(&format!(
483            "[{:.6}] native_terminal_input raw bKeyDown=0 vk={:#06x} scan={:#06x} unicode={:#06x} control={:#010x} repeat={} translated=ignored",
484            unix_now_seconds(),
485            record.wVirtualKeyCode,
486            record.wVirtualScanCode,
487            unsafe { *record.uChar.UnicodeChar() },
488            record.dwControlKeyState,
489            record.wRepeatCount,
490        ));
491        return None;
492    }
493
494    let repeat_count = record.wRepeatCount.max(1);
495    let modifiers = record.dwControlKeyState;
496    let shift = modifiers & SHIFT_PRESSED != 0;
497    let alt = modifiers & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED) != 0;
498    let ctrl = modifiers & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED) != 0;
499    let virtual_key_code = record.wVirtualKeyCode;
500    let unicode = unsafe { *record.uChar.UnicodeChar() };
501
502    // Shift+Enter: send CSI u escape sequence so downstream TUI apps
503    // (e.g. Claude Code) can distinguish Shift+Enter (newline) from
504    // plain Enter (submit).  Format: ESC [ 13 ; 2 u
505    if shift && !ctrl && !alt && virtual_key_code as i32 == VK_RETURN {
506        return Some(trace_translated_console_key_event(
507            record,
508            TerminalInputEventRecord {
509                data: repeat_terminal_input_bytes(b"\x1b[13;2u", repeat_count),
510                submit: false,
511                shift,
512                ctrl,
513                alt,
514                virtual_key_code,
515                repeat_count,
516            },
517        ));
518    }
519
520    let mut data = if ctrl {
521        control_character_for_unicode(unicode)
522            .map(|byte| repeat_terminal_input_bytes(&[byte], repeat_count))
523            .unwrap_or_default()
524    } else {
525        Vec::new()
526    };
527
528    if data.is_empty() && unicode != 0 {
529        if let Some(character) = char::from_u32(u32::from(unicode)) {
530            let text: String = std::iter::repeat_n(character, usize::from(repeat_count)).collect();
531            data = text.into_bytes();
532        }
533    }
534
535    if data.is_empty() {
536        let modifier = terminal_input_modifier_parameter(shift, alt, ctrl);
537        let sequence = match virtual_key_code as i32 {
538            VK_BACK => Some(b"\x08".as_slice()),
539            VK_TAB if shift => Some(b"\x1b[Z".as_slice()),
540            VK_TAB => Some(b"\t".as_slice()),
541            VK_RETURN => Some(b"\r".as_slice()),
542            VK_ESCAPE => Some(b"\x1b".as_slice()),
543            VK_UP => {
544                return Some(trace_translated_console_key_event(
545                    record,
546                    TerminalInputEventRecord {
547                        data: repeated_modified_sequence(b"\x1b[A", modifier, repeat_count),
548                        submit: false,
549                        shift,
550                        ctrl,
551                        alt,
552                        virtual_key_code,
553                        repeat_count,
554                    },
555                ));
556            }
557            VK_DOWN => {
558                return Some(trace_translated_console_key_event(
559                    record,
560                    TerminalInputEventRecord {
561                        data: repeated_modified_sequence(b"\x1b[B", modifier, repeat_count),
562                        submit: false,
563                        shift,
564                        ctrl,
565                        alt,
566                        virtual_key_code,
567                        repeat_count,
568                    },
569                ));
570            }
571            VK_RIGHT => {
572                return Some(trace_translated_console_key_event(
573                    record,
574                    TerminalInputEventRecord {
575                        data: repeated_modified_sequence(b"\x1b[C", modifier, repeat_count),
576                        submit: false,
577                        shift,
578                        ctrl,
579                        alt,
580                        virtual_key_code,
581                        repeat_count,
582                    },
583                ));
584            }
585            VK_LEFT => {
586                return Some(trace_translated_console_key_event(
587                    record,
588                    TerminalInputEventRecord {
589                        data: repeated_modified_sequence(b"\x1b[D", modifier, repeat_count),
590                        submit: false,
591                        shift,
592                        ctrl,
593                        alt,
594                        virtual_key_code,
595                        repeat_count,
596                    },
597                ));
598            }
599            VK_HOME => {
600                return Some(trace_translated_console_key_event(
601                    record,
602                    TerminalInputEventRecord {
603                        data: repeated_modified_sequence(b"\x1b[H", modifier, repeat_count),
604                        submit: false,
605                        shift,
606                        ctrl,
607                        alt,
608                        virtual_key_code,
609                        repeat_count,
610                    },
611                ));
612            }
613            VK_END => {
614                return Some(trace_translated_console_key_event(
615                    record,
616                    TerminalInputEventRecord {
617                        data: repeated_modified_sequence(b"\x1b[F", modifier, repeat_count),
618                        submit: false,
619                        shift,
620                        ctrl,
621                        alt,
622                        virtual_key_code,
623                        repeat_count,
624                    },
625                ));
626            }
627            VK_INSERT => {
628                return Some(trace_translated_console_key_event(
629                    record,
630                    TerminalInputEventRecord {
631                        data: repeated_tilde_sequence(2, modifier, repeat_count),
632                        submit: false,
633                        shift,
634                        ctrl,
635                        alt,
636                        virtual_key_code,
637                        repeat_count,
638                    },
639                ));
640            }
641            VK_DELETE => {
642                return Some(trace_translated_console_key_event(
643                    record,
644                    TerminalInputEventRecord {
645                        data: repeated_tilde_sequence(3, modifier, repeat_count),
646                        submit: false,
647                        shift,
648                        ctrl,
649                        alt,
650                        virtual_key_code,
651                        repeat_count,
652                    },
653                ));
654            }
655            VK_PRIOR => {
656                return Some(trace_translated_console_key_event(
657                    record,
658                    TerminalInputEventRecord {
659                        data: repeated_tilde_sequence(5, modifier, repeat_count),
660                        submit: false,
661                        shift,
662                        ctrl,
663                        alt,
664                        virtual_key_code,
665                        repeat_count,
666                    },
667                ));
668            }
669            VK_NEXT => {
670                return Some(trace_translated_console_key_event(
671                    record,
672                    TerminalInputEventRecord {
673                        data: repeated_tilde_sequence(6, modifier, repeat_count),
674                        submit: false,
675                        shift,
676                        ctrl,
677                        alt,
678                        virtual_key_code,
679                        repeat_count,
680                    },
681                ));
682            }
683            _ => None,
684        };
685        data = sequence.map(|chunk| repeat_terminal_input_bytes(chunk, repeat_count))?;
686    }
687
688    if alt && !data.starts_with(b"\x1b[") && !data.starts_with(b"\x1bO") {
689        let mut prefixed = Vec::with_capacity(data.len() + 1);
690        prefixed.push(0x1B);
691        prefixed.extend_from_slice(&data);
692        data = prefixed;
693    }
694
695    let event = TerminalInputEventRecord {
696        data,
697        submit: virtual_key_code as i32 == VK_RETURN && !shift,
698        shift,
699        ctrl,
700        alt,
701        virtual_key_code,
702        repeat_count,
703    };
704    Some(trace_translated_console_key_event(record, event))
705}
706
707#[cfg(windows)]
708fn native_terminal_input_worker(
709    input_handle: usize,
710    state: Arc<Mutex<TerminalInputState>>,
711    condvar: Arc<Condvar>,
712    stop: Arc<AtomicBool>,
713    capturing: Arc<AtomicBool>,
714) {
715    use winapi::shared::minwindef::DWORD;
716    use winapi::shared::winerror::WAIT_TIMEOUT;
717    use winapi::um::consoleapi::ReadConsoleInputW;
718    use winapi::um::synchapi::WaitForSingleObject;
719    use winapi::um::winbase::WAIT_OBJECT_0;
720    use winapi::um::wincontypes::{INPUT_RECORD, KEY_EVENT};
721    use winapi::um::winnt::HANDLE;
722
723    let handle = input_handle as HANDLE;
724    let mut records: [INPUT_RECORD; 16] = unsafe { std::mem::zeroed() };
725    append_native_terminal_input_trace_line(&format!(
726        "[{:.6}] native_terminal_input worker_start handle={input_handle}",
727        unix_now_seconds(),
728    ));
729
730    while !stop.load(Ordering::Acquire) {
731        let wait_result = unsafe { WaitForSingleObject(handle, 50) };
732        match wait_result {
733            WAIT_OBJECT_0 => {
734                let mut read_count: DWORD = 0;
735                let ok = unsafe {
736                    ReadConsoleInputW(
737                        handle,
738                        records.as_mut_ptr(),
739                        records.len() as DWORD,
740                        &mut read_count,
741                    )
742                };
743                if ok == 0 {
744                    append_native_terminal_input_trace_line(&format!(
745                        "[{:.6}] native_terminal_input read_console_input_failed handle={input_handle}",
746                        unix_now_seconds(),
747                    ));
748                    break;
749                }
750                for record in records.iter().take(read_count as usize) {
751                    if record.EventType != KEY_EVENT {
752                        continue;
753                    }
754                    let key_event = unsafe { record.Event.KeyEvent() };
755                    if let Some(event) = translate_console_key_event(key_event) {
756                        let mut guard = state.lock().expect("terminal input mutex poisoned");
757                        guard.events.push_back(event);
758                        drop(guard);
759                        condvar.notify_all();
760                    }
761                }
762            }
763            WAIT_TIMEOUT => continue,
764            _ => {
765                append_native_terminal_input_trace_line(&format!(
766                    "[{:.6}] native_terminal_input wait_result={wait_result} handle={input_handle}",
767                    unix_now_seconds(),
768                ));
769                break;
770            }
771        }
772    }
773
774    capturing.store(false, Ordering::Release);
775    let mut guard = state.lock().expect("terminal input mutex poisoned");
776    guard.closed = true;
777    condvar.notify_all();
778    drop(guard);
779    append_native_terminal_input_trace_line(&format!(
780        "[{:.6}] native_terminal_input worker_stop handle={input_handle}",
781        unix_now_seconds(),
782    ));
783}
784
785#[pyfunction]
786fn native_get_process_tree_info(pid: u32) -> String {
787    let system = System::new_all();
788    let pid = system_pid(pid);
789    let Some(process) = system.process(pid) else {
790        return format!("Could not get process info for PID {}", pid.as_u32());
791    };
792
793    let mut info = vec![
794        format!("Process {} ({})", pid.as_u32(), process.name()),
795        format!("Status: {:?}", process.status()),
796    ];
797    let children = descendant_pids(&system, pid);
798    if !children.is_empty() {
799        info.push("Child processes:".to_string());
800        for child_pid in children {
801            if let Some(child) = system.process(child_pid) {
802                info.push(format!("  Child {} ({})", child_pid.as_u32(), child.name()));
803            }
804        }
805    }
806    info.join("\n")
807}
808
809#[pyfunction]
810#[pyo3(signature = (pid, timeout_seconds=3.0))]
811fn native_kill_process_tree(pid: u32, timeout_seconds: f64) {
812    kill_process_tree_impl(pid, timeout_seconds);
813}
814
815#[pyfunction]
816fn native_process_created_at(pid: u32) -> Option<f64> {
817    process_created_at(pid)
818}
819
820#[pyfunction]
821#[pyo3(signature = (pid, created_at, tolerance_seconds=1.0))]
822fn native_is_same_process(pid: u32, created_at: f64, tolerance_seconds: f64) -> bool {
823    same_process_identity(pid, created_at, tolerance_seconds)
824}
825
826#[pyfunction]
827#[pyo3(signature = (tolerance_seconds=1.0, kill_timeout_seconds=3.0))]
828fn native_cleanup_tracked_processes(
829    tolerance_seconds: f64,
830    kill_timeout_seconds: f64,
831) -> PyResult<Vec<TrackedProcessEntry>> {
832    let entries = list_tracked_processes()?;
833
834    let mut killed = Vec::new();
835    for entry in entries {
836        let pid = entry.0;
837        if !same_process_identity(pid, entry.1, tolerance_seconds) {
838            unregister_active_process(pid);
839            continue;
840        }
841        kill_process_tree_impl(pid, kill_timeout_seconds);
842        unregister_active_process(pid);
843        killed.push(entry);
844    }
845    Ok(killed)
846}
847
848#[pyfunction]
849fn native_list_active_processes() -> Vec<ActiveProcessEntry> {
850    let registry = active_process_registry()
851        .lock()
852        .expect("active process registry mutex poisoned");
853    let mut items: Vec<_> = registry
854        .values()
855        .map(|entry| {
856            (
857                entry.pid,
858                entry.kind.clone(),
859                entry.command.clone(),
860                entry.cwd.clone(),
861                entry.started_at,
862            )
863        })
864        .collect();
865    items.sort_by(|left, right| {
866        left.4
867            .partial_cmp(&right.4)
868            .unwrap_or(std::cmp::Ordering::Equal)
869            .then_with(|| left.0.cmp(&right.0))
870    });
871    items
872}
873
874#[pyfunction]
875#[inline(never)]
876fn native_apply_process_nice(pid: u32, nice: i32) -> PyResult<()> {
877    public_symbols::rp_native_apply_process_nice_public(pid, nice)
878}
879
880fn native_apply_process_nice_impl(pid: u32, nice: i32) -> PyResult<()> {
881    running_process_core::rp_rust_debug_scope!("running_process_py::native_apply_process_nice");
882    #[cfg(windows)]
883    {
884        public_symbols::rp_windows_apply_process_priority_public(pid, nice)
885    }
886
887    #[cfg(unix)]
888    {
889        unix_set_priority(pid, nice).map_err(to_py_err)
890    }
891}
892
893#[cfg(windows)]
894fn windows_apply_process_priority_impl(pid: u32, nice: i32) -> PyResult<()> {
895    running_process_core::rp_rust_debug_scope!(
896        "running_process_py::windows_apply_process_priority"
897    );
898    use winapi::um::handleapi::CloseHandle;
899    use winapi::um::processthreadsapi::{OpenProcess, SetPriorityClass};
900    use winapi::um::winbase::{
901        ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, HIGH_PRIORITY_CLASS,
902        IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS,
903    };
904    use winapi::um::winnt::{PROCESS_QUERY_INFORMATION, PROCESS_SET_INFORMATION};
905
906    let priority_class = if nice >= 15 {
907        IDLE_PRIORITY_CLASS
908    } else if nice >= 1 {
909        BELOW_NORMAL_PRIORITY_CLASS
910    } else if nice <= -15 {
911        HIGH_PRIORITY_CLASS
912    } else if nice <= -1 {
913        ABOVE_NORMAL_PRIORITY_CLASS
914    } else {
915        NORMAL_PRIORITY_CLASS
916    };
917
918    let handle =
919        unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SET_INFORMATION, 0, pid) };
920    if handle.is_null() {
921        return Err(to_py_err(std::io::Error::last_os_error()));
922    }
923    let result = unsafe { SetPriorityClass(handle, priority_class) };
924    let close_result = unsafe { CloseHandle(handle) };
925    if close_result == 0 {
926        return Err(to_py_err(std::io::Error::last_os_error()));
927    }
928    if result == 0 {
929        return Err(to_py_err(std::io::Error::last_os_error()));
930    }
931    Ok(())
932}
933
934#[cfg(windows)]
935fn windows_generate_console_ctrl_break_impl(pid: u32, creationflags: Option<u32>) -> PyResult<()> {
936    running_process_core::rp_rust_debug_scope!(
937        "running_process_py::windows_generate_console_ctrl_break"
938    );
939    use winapi::um::wincon::{GenerateConsoleCtrlEvent, CTRL_BREAK_EVENT};
940
941    let new_process_group =
942        creationflags.unwrap_or(0) & winapi::um::winbase::CREATE_NEW_PROCESS_GROUP;
943    if new_process_group == 0 {
944        return Err(PyRuntimeError::new_err(
945            "send_interrupt on Windows requires CREATE_NEW_PROCESS_GROUP",
946        ));
947    }
948    let result = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid) };
949    if result == 0 {
950        return Err(to_py_err(std::io::Error::last_os_error()));
951    }
952    Ok(())
953}
954
955#[pyfunction]
956fn native_windows_terminal_input_bytes(py: Python<'_>, data: &[u8]) -> Py<PyAny> {
957    #[cfg(windows)]
958    let payload = windows_terminal_input_payload(data);
959    #[cfg(not(windows))]
960    let payload = data.to_vec();
961    PyBytes::new(py, &payload).into_any().unbind()
962}
963
964#[pyfunction]
965fn native_dump_rust_debug_traces() -> String {
966    render_rust_debug_traces()
967}
968
969#[pyfunction]
970fn native_test_capture_rust_debug_trace() -> String {
971    #[inline(never)]
972    fn inner() -> String {
973        running_process_core::rp_rust_debug_scope!(
974            "running_process_py::native_test_capture_rust_debug_trace::inner"
975        );
976        render_rust_debug_traces()
977    }
978
979    #[inline(never)]
980    fn outer() -> String {
981        running_process_core::rp_rust_debug_scope!(
982            "running_process_py::native_test_capture_rust_debug_trace::outer"
983        );
984        inner()
985    }
986
987    outer()
988}
989
990#[cfg(windows)]
991#[no_mangle]
992#[inline(never)]
993pub fn running_process_py_debug_hang_inner(release_path: &std::path::Path) -> PyResult<()> {
994    running_process_core::rp_rust_debug_scope!("running_process_py::debug_hang_inner");
995    while !release_path.exists() {
996        std::hint::spin_loop();
997    }
998    Ok(())
999}
1000
1001#[cfg(windows)]
1002#[no_mangle]
1003#[inline(never)]
1004pub fn running_process_py_debug_hang_outer(
1005    ready_path: &std::path::Path,
1006    release_path: &std::path::Path,
1007) -> PyResult<()> {
1008    running_process_core::rp_rust_debug_scope!("running_process_py::debug_hang_outer");
1009    fs::write(ready_path, b"ready").map_err(to_py_err)?;
1010    running_process_py_debug_hang_inner(release_path)
1011}
1012
1013#[pyfunction]
1014#[cfg(windows)]
1015#[inline(never)]
1016fn native_test_hang_in_rust(ready_path: String, release_path: String) -> PyResult<()> {
1017    running_process_core::rp_rust_debug_scope!("running_process_py::native_test_hang_in_rust");
1018    running_process_py_debug_hang_outer(
1019        std::path::Path::new(&ready_path),
1020        std::path::Path::new(&release_path),
1021    )
1022}
1023
1024#[pymethods]
1025impl NativeProcessMetrics {
1026    #[new]
1027    fn new(pid: u32) -> Self {
1028        let pid = system_pid(pid);
1029        let mut system = System::new();
1030        system.refresh_process_specifics(
1031            pid,
1032            ProcessRefreshKind::new()
1033                .with_cpu()
1034                .with_disk_usage()
1035                .with_memory()
1036                .with_exe(UpdateKind::Never),
1037        );
1038        Self {
1039            pid,
1040            system: Mutex::new(system),
1041        }
1042    }
1043
1044    fn prime(&self) {
1045        let mut system = self.system.lock().expect("process metrics mutex poisoned");
1046        system.refresh_process_specifics(
1047            self.pid,
1048            ProcessRefreshKind::new()
1049                .with_cpu()
1050                .with_disk_usage()
1051                .with_memory()
1052                .with_exe(UpdateKind::Never),
1053        );
1054    }
1055
1056    fn sample(&self) -> (bool, f32, u64, u64) {
1057        let mut system = self.system.lock().expect("process metrics mutex poisoned");
1058        system.refresh_process_specifics(
1059            self.pid,
1060            ProcessRefreshKind::new()
1061                .with_cpu()
1062                .with_disk_usage()
1063                .with_memory()
1064                .with_exe(UpdateKind::Never),
1065        );
1066        let Some(process) = system.process(self.pid) else {
1067            return (false, 0.0, 0, 0);
1068        };
1069        let disk = process.disk_usage();
1070        (
1071            true,
1072            process.cpu_usage(),
1073            disk.total_read_bytes
1074                .saturating_add(disk.total_written_bytes),
1075            0,
1076        )
1077    }
1078}
1079
1080struct PtyReadState {
1081    chunks: VecDeque<Vec<u8>>,
1082    closed: bool,
1083}
1084
1085struct PtyReadShared {
1086    state: Mutex<PtyReadState>,
1087    condvar: Condvar,
1088}
1089
1090struct NativePtyHandles {
1091    master: Box<dyn MasterPty + Send>,
1092    writer: Box<dyn Write + Send>,
1093    child: Box<dyn portable_pty::Child + Send + Sync>,
1094    #[cfg(windows)]
1095    _job: WindowsJobHandle,
1096}
1097
1098#[pyclass]
1099struct NativeProcessMetrics {
1100    pid: Pid,
1101    system: Mutex<System>,
1102}
1103
1104#[pyclass]
1105struct NativePtyProcess {
1106    argv: Vec<String>,
1107    cwd: Option<String>,
1108    env: Option<Vec<(String, String)>>,
1109    rows: u16,
1110    cols: u16,
1111    #[cfg(windows)]
1112    nice: Option<i32>,
1113    handles: Arc<Mutex<Option<NativePtyHandles>>>,
1114    reader: Arc<PtyReadShared>,
1115    returncode: Arc<Mutex<Option<i32>>>,
1116    input_bytes_total: Arc<AtomicUsize>,
1117    newline_events_total: Arc<AtomicUsize>,
1118    submit_events_total: Arc<AtomicUsize>,
1119    /// When true, the reader thread writes PTY output to stdout.
1120    echo: Arc<AtomicBool>,
1121    /// When set, the reader thread feeds output directly to the idle detector.
1122    idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
1123    /// Visible (non-control) output bytes seen by the reader thread.
1124    output_bytes_total: Arc<AtomicUsize>,
1125    /// Control churn bytes (ANSI escapes, BS, CR, DEL) seen by the reader.
1126    control_churn_bytes_total: Arc<AtomicUsize>,
1127    #[cfg(windows)]
1128    terminal_input_relay_stop: Arc<AtomicBool>,
1129    #[cfg(windows)]
1130    terminal_input_relay_active: Arc<AtomicBool>,
1131    #[cfg(windows)]
1132    terminal_input_relay_worker: Mutex<Option<thread::JoinHandle<()>>>,
1133}
1134
1135impl NativePtyProcess {
1136    fn mark_reader_closed(&self) {
1137        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
1138        guard.closed = true;
1139        self.reader.condvar.notify_all();
1140    }
1141
1142    fn store_returncode(&self, code: i32) {
1143        store_pty_returncode(&self.returncode, code);
1144    }
1145
1146    fn record_input_metrics(&self, data: &[u8], submit: bool) {
1147        record_pty_input_metrics(
1148            &self.input_bytes_total,
1149            &self.newline_events_total,
1150            &self.submit_events_total,
1151            data,
1152            submit,
1153        );
1154    }
1155
1156    fn write_impl(&self, data: &[u8], submit: bool) -> PyResult<()> {
1157        self.record_input_metrics(data, submit);
1158        write_pty_input(&self.handles, data).map_err(to_py_err)
1159    }
1160
1161    #[cfg(windows)]
1162    fn request_terminal_input_relay_stop(&self) {
1163        self.terminal_input_relay_stop
1164            .store(true, Ordering::Release);
1165        self.terminal_input_relay_active
1166            .store(false, Ordering::Release);
1167    }
1168
1169    #[cfg(windows)]
1170    fn stop_terminal_input_relay_impl(&self) {
1171        self.request_terminal_input_relay_stop();
1172        if let Some(worker) = self
1173            .terminal_input_relay_worker
1174            .lock()
1175            .expect("pty terminal input relay mutex poisoned")
1176            .take()
1177        {
1178            let _ = worker.join();
1179        }
1180    }
1181
1182    #[cfg(not(windows))]
1183    fn stop_terminal_input_relay_impl(&self) {}
1184
1185    #[cfg(windows)]
1186    fn start_terminal_input_relay_impl(&self) -> PyResult<()> {
1187        let mut worker_guard = self
1188            .terminal_input_relay_worker
1189            .lock()
1190            .expect("pty terminal input relay mutex poisoned");
1191        if worker_guard.is_some() && self.terminal_input_relay_active.load(Ordering::Acquire) {
1192            return Ok(());
1193        }
1194        if self
1195            .handles
1196            .lock()
1197            .expect("pty handles mutex poisoned")
1198            .is_none()
1199        {
1200            return Err(PyRuntimeError::new_err(
1201                "Pseudo-terminal process is not running",
1202            ));
1203        }
1204
1205        let capture = NativeTerminalInput::new();
1206        capture.start_impl()?;
1207
1208        self.terminal_input_relay_stop
1209            .store(false, Ordering::Release);
1210        self.terminal_input_relay_active
1211            .store(true, Ordering::Release);
1212
1213        let handles = Arc::clone(&self.handles);
1214        let returncode = Arc::clone(&self.returncode);
1215        let input_bytes_total = Arc::clone(&self.input_bytes_total);
1216        let newline_events_total = Arc::clone(&self.newline_events_total);
1217        let submit_events_total = Arc::clone(&self.submit_events_total);
1218        let stop = Arc::clone(&self.terminal_input_relay_stop);
1219        let active = Arc::clone(&self.terminal_input_relay_active);
1220
1221        *worker_guard = Some(thread::spawn(move || {
1222            loop {
1223                if stop.load(Ordering::Acquire) {
1224                    break;
1225                }
1226                match poll_pty_process(&handles, &returncode) {
1227                    Ok(Some(_)) => break,
1228                    Ok(None) => {}
1229                    Err(_) => break,
1230                }
1231                match wait_for_terminal_input_event(
1232                    &capture.state,
1233                    &capture.condvar,
1234                    Some(Duration::from_millis(50)),
1235                ) {
1236                    TerminalInputWaitOutcome::Event(event) => {
1237                        record_pty_input_metrics(
1238                            &input_bytes_total,
1239                            &newline_events_total,
1240                            &submit_events_total,
1241                            &event.data,
1242                            event.submit,
1243                        );
1244                        if write_pty_input(&handles, &event.data).is_err() {
1245                            break;
1246                        }
1247                    }
1248                    TerminalInputWaitOutcome::Timeout => continue,
1249                    TerminalInputWaitOutcome::Closed => break,
1250                }
1251            }
1252            stop.store(true, Ordering::Release);
1253            active.store(false, Ordering::Release);
1254            let _ = capture.stop_impl();
1255        }));
1256        Ok(())
1257    }
1258
1259    #[cfg(not(windows))]
1260    fn start_terminal_input_relay_impl(&self) -> PyResult<()> {
1261        Err(PyRuntimeError::new_err(
1262            "Native PTY terminal input relay is only available on Windows consoles",
1263        ))
1264    }
1265
1266    /// Synchronously tear down the PTY and reap the child.
1267    ///
1268    /// This MUST NOT be called while holding the Python GIL — `child.wait()`
1269    /// can block indefinitely on Windows ConPTY (the child stays alive until
1270    /// every handle to the master pipe is dropped, including the one held by
1271    /// the background reader thread). Always wrap this in `py.allow_threads`
1272    /// from a Python-callable method.
1273    // Preserve a stable Rust frame here in release user dumps.
1274    #[inline(never)]
1275    fn close_impl(&self) -> PyResult<()> {
1276        running_process_core::rp_rust_debug_scope!(
1277            "running_process_py::NativePtyProcess::close_impl"
1278        );
1279        self.stop_terminal_input_relay_impl();
1280        let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
1281        let Some(handles) = guard.take() else {
1282            self.mark_reader_closed();
1283            return Ok(());
1284        };
1285        // Release the lock while we wait so other threads can still touch
1286        // unrelated fields on this object (e.g. the reader buffer).
1287        drop(guard);
1288
1289        #[cfg(windows)]
1290        let NativePtyHandles {
1291            master,
1292            writer,
1293            mut child,
1294            _job,
1295        } = handles;
1296        #[cfg(not(windows))]
1297        let NativePtyHandles {
1298            master,
1299            writer,
1300            mut child,
1301        } = handles;
1302
1303        // Kill first so the child has stopped writing before we tear down
1304        // ConPTY. On Windows, ClosePseudoConsole (triggered by dropping
1305        // master) does not always terminate the child, so we explicitly
1306        // TerminateProcess it.
1307        if let Err(err) = child.kill() {
1308            if !is_ignorable_process_control_error(&err) {
1309                return Err(to_py_err(err));
1310            }
1311        }
1312
1313        // Drop the writer/master so the background reader thread sees EOF
1314        // and releases its handle. Otherwise the reader stays blocked
1315        // forever holding a master clone, which keeps ConPTY alive.
1316        drop(writer);
1317        drop(master);
1318
1319        // Now block until the child is reaped. This is safe to call
1320        // unbounded because `close()` invoked us inside `py.allow_threads`,
1321        // so the GIL is released and other Python threads can make
1322        // progress. After the explicit kill() above, this returns within
1323        // milliseconds in practice.
1324        let code = match child.wait() {
1325            Ok(status) => portable_exit_code(status),
1326            Err(_) => -9,
1327        };
1328        drop(child);
1329        #[cfg(windows)]
1330        drop(_job);
1331
1332        self.store_returncode(code);
1333        self.mark_reader_closed();
1334        Ok(())
1335    }
1336
1337    /// Best-effort, non-blocking teardown for use from `Drop`.
1338    ///
1339    /// `Drop` runs while Python holds the GIL (it is invoked by PyO3 during
1340    /// finalization), so we cannot call any blocking syscalls here. We kill
1341    /// the child, drop every handle so the OS reclaims the file descriptors,
1342    /// and let the OS reap the process. The background reader thread will
1343    /// notice EOF on its master clone and exit on its own.
1344    // Preserve a stable Rust frame here in release user dumps.
1345    #[inline(never)]
1346    fn close_nonblocking(&self) {
1347        running_process_core::rp_rust_debug_scope!(
1348            "running_process_py::NativePtyProcess::close_nonblocking"
1349        );
1350        #[cfg(windows)]
1351        self.request_terminal_input_relay_stop();
1352        let Ok(mut guard) = self.handles.lock() else {
1353            return;
1354        };
1355        let Some(handles) = guard.take() else {
1356            self.mark_reader_closed();
1357            return;
1358        };
1359        drop(guard);
1360
1361        #[cfg(windows)]
1362        let NativePtyHandles {
1363            master,
1364            writer,
1365            mut child,
1366            _job,
1367        } = handles;
1368        #[cfg(not(windows))]
1369        let NativePtyHandles {
1370            master,
1371            writer,
1372            mut child,
1373        } = handles;
1374
1375        if let Err(err) = child.kill() {
1376            if !is_ignorable_process_control_error(&err) {
1377                return;
1378            }
1379        }
1380        // Drop writer + master so the reader thread sees EOF immediately.
1381        drop(writer);
1382        drop(master);
1383        // Do NOT call child.wait() here — that would block the interpreter.
1384        // Drop on the child closes its OS handle; the process is reaped by
1385        // the OS once it actually exits.
1386        drop(child);
1387        #[cfg(windows)]
1388        drop(_job);
1389        self.mark_reader_closed();
1390    }
1391
1392    fn start_impl(&self) -> PyResult<()> {
1393        running_process_core::rp_rust_debug_scope!("running_process_py::NativePtyProcess::start");
1394        let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
1395        if guard.is_some() {
1396            return Err(PyRuntimeError::new_err(
1397                "Pseudo-terminal process already started",
1398            ));
1399        }
1400
1401        let pty_system = native_pty_system();
1402        let pair = pty_system
1403            .openpty(PtySize {
1404                rows: self.rows,
1405                cols: self.cols,
1406                pixel_width: 0,
1407                pixel_height: 0,
1408            })
1409            .map_err(to_py_err)?;
1410
1411        let mut cmd = command_builder_from_argv(&self.argv);
1412        if let Some(cwd) = &self.cwd {
1413            cmd.cwd(cwd);
1414        }
1415        if let Some(env) = &self.env {
1416            cmd.env_clear();
1417            for (key, value) in env {
1418                cmd.env(key, value);
1419            }
1420        }
1421
1422        let reader = pair.master.try_clone_reader().map_err(to_py_err)?;
1423        let writer = pair.master.take_writer().map_err(to_py_err)?;
1424        let child = pair.slave.spawn_command(cmd).map_err(to_py_err)?;
1425        #[cfg(windows)]
1426        let job = public_symbols::rp_py_assign_child_to_windows_kill_on_close_job_public(
1427            child.as_raw_handle(),
1428        )?;
1429        #[cfg(windows)]
1430        public_symbols::rp_apply_windows_pty_priority_public(child.as_raw_handle(), self.nice)?;
1431        let shared = Arc::clone(&self.reader);
1432        let echo = Arc::clone(&self.echo);
1433        let idle_detector = Arc::clone(&self.idle_detector);
1434        let output_bytes = Arc::clone(&self.output_bytes_total);
1435        let churn_bytes = Arc::clone(&self.control_churn_bytes_total);
1436        thread::spawn(move || {
1437            spawn_pty_reader(
1438                reader,
1439                shared,
1440                echo,
1441                idle_detector,
1442                output_bytes,
1443                churn_bytes,
1444            );
1445        });
1446
1447        *guard = Some(NativePtyHandles {
1448            master: pair.master,
1449            writer,
1450            child,
1451            #[cfg(windows)]
1452            _job: job,
1453        });
1454        Ok(())
1455    }
1456
1457    fn respond_to_queries_impl(&self, data: &[u8]) -> PyResult<()> {
1458        #[cfg(windows)]
1459        {
1460            public_symbols::rp_pty_windows_respond_to_queries_public(self, data)
1461        }
1462
1463        #[cfg(unix)]
1464        {
1465            pty_platform::respond_to_queries(self, data)
1466        }
1467    }
1468
1469    fn resize_impl(&self, rows: u16, cols: u16) -> PyResult<()> {
1470        running_process_core::rp_rust_debug_scope!("running_process_py::NativePtyProcess::resize");
1471        let guard = self.handles.lock().expect("pty handles mutex poisoned");
1472        if let Some(handles) = guard.as_ref() {
1473            handles
1474                .master
1475                .resize(PtySize {
1476                    rows,
1477                    cols,
1478                    pixel_width: 0,
1479                    pixel_height: 0,
1480                })
1481                .map_err(to_py_err)?;
1482        }
1483        Ok(())
1484    }
1485
1486    fn send_interrupt_impl(&self) -> PyResult<()> {
1487        running_process_core::rp_rust_debug_scope!(
1488            "running_process_py::NativePtyProcess::send_interrupt"
1489        );
1490        #[cfg(windows)]
1491        {
1492            public_symbols::rp_pty_windows_send_interrupt_public(self)
1493        }
1494
1495        #[cfg(unix)]
1496        {
1497            pty_platform::send_interrupt(self)
1498        }
1499    }
1500
1501    fn wait_impl(&self, timeout: Option<f64>) -> PyResult<i32> {
1502        running_process_core::rp_rust_debug_scope!("running_process_py::NativePtyProcess::wait");
1503        let start = Instant::now();
1504        loop {
1505            if let Some(code) = self.poll()? {
1506                return Ok(code);
1507            }
1508            if timeout.is_some_and(|limit| start.elapsed() >= Duration::from_secs_f64(limit)) {
1509                return Err(PyTimeoutError::new_err("Pseudo-terminal process timed out"));
1510            }
1511            thread::sleep(Duration::from_millis(10));
1512        }
1513    }
1514
1515    fn terminate_impl(&self) -> PyResult<()> {
1516        running_process_core::rp_rust_debug_scope!(
1517            "running_process_py::NativePtyProcess::terminate"
1518        );
1519        #[cfg(windows)]
1520        {
1521            public_symbols::rp_pty_windows_terminate_public(self)
1522        }
1523
1524        #[cfg(unix)]
1525        {
1526            pty_platform::terminate(self)
1527        }
1528    }
1529
1530    fn kill_impl(&self) -> PyResult<()> {
1531        running_process_core::rp_rust_debug_scope!("running_process_py::NativePtyProcess::kill");
1532        #[cfg(windows)]
1533        {
1534            public_symbols::rp_pty_windows_kill_public(self)
1535        }
1536
1537        #[cfg(unix)]
1538        {
1539            pty_platform::kill(self)
1540        }
1541    }
1542
1543    fn terminate_tree_impl(&self) -> PyResult<()> {
1544        running_process_core::rp_rust_debug_scope!(
1545            "running_process_py::NativePtyProcess::terminate_tree"
1546        );
1547        #[cfg(windows)]
1548        {
1549            public_symbols::rp_pty_windows_terminate_tree_public(self)
1550        }
1551
1552        #[cfg(unix)]
1553        {
1554            pty_platform::terminate_tree(self)
1555        }
1556    }
1557
1558    fn kill_tree_impl(&self) -> PyResult<()> {
1559        running_process_core::rp_rust_debug_scope!(
1560            "running_process_py::NativePtyProcess::kill_tree"
1561        );
1562        #[cfg(windows)]
1563        {
1564            public_symbols::rp_pty_windows_kill_tree_public(self)
1565        }
1566
1567        #[cfg(unix)]
1568        {
1569            pty_platform::kill_tree(self)
1570        }
1571    }
1572}
1573
1574#[cfg(windows)]
1575struct WindowsJobHandle(usize);
1576
1577#[cfg(windows)]
1578impl Drop for WindowsJobHandle {
1579    fn drop(&mut self) {
1580        unsafe {
1581            winapi::um::handleapi::CloseHandle(self.0 as winapi::shared::ntdef::HANDLE);
1582        }
1583    }
1584}
1585
1586fn parse_command(command: &Bound<'_, PyAny>, shell: bool) -> PyResult<CommandSpec> {
1587    if let Ok(command) = command.extract::<String>() {
1588        if !shell {
1589            return Err(PyValueError::new_err(
1590                "String commands require shell=True. Use shell=True or provide command as list[str].",
1591            ));
1592        }
1593        return Ok(CommandSpec::Shell(command));
1594    }
1595
1596    if let Ok(command) = command.downcast::<PyList>() {
1597        let argv = command.extract::<Vec<String>>()?;
1598        if argv.is_empty() {
1599            return Err(PyValueError::new_err("command cannot be empty"));
1600        }
1601        if shell {
1602            return Ok(CommandSpec::Shell(argv.join(" ")));
1603        }
1604        return Ok(CommandSpec::Argv(argv));
1605    }
1606
1607    Err(PyValueError::new_err(
1608        "command must be either a string or a list[str]",
1609    ))
1610}
1611
1612fn stream_kind(name: &str) -> PyResult<StreamKind> {
1613    match name {
1614        "stdout" => Ok(StreamKind::Stdout),
1615        "stderr" => Ok(StreamKind::Stderr),
1616        _ => Err(PyValueError::new_err("stream must be 'stdout' or 'stderr'")),
1617    }
1618}
1619
1620fn stdin_mode(name: &str) -> PyResult<StdinMode> {
1621    match name {
1622        "inherit" => Ok(StdinMode::Inherit),
1623        "piped" => Ok(StdinMode::Piped),
1624        "null" => Ok(StdinMode::Null),
1625        _ => Err(PyValueError::new_err(
1626            "stdin_mode must be 'inherit', 'piped', or 'null'",
1627        )),
1628    }
1629}
1630
1631fn stderr_mode(name: &str) -> PyResult<StderrMode> {
1632    match name {
1633        "stdout" => Ok(StderrMode::Stdout),
1634        "pipe" => Ok(StderrMode::Pipe),
1635        _ => Err(PyValueError::new_err(
1636            "stderr_mode must be 'stdout' or 'pipe'",
1637        )),
1638    }
1639}
1640
1641#[pyclass]
1642struct NativeRunningProcess {
1643    inner: NativeProcess,
1644    text: bool,
1645    encoding: Option<String>,
1646    errors: Option<String>,
1647    #[cfg(windows)]
1648    creationflags: Option<u32>,
1649    #[cfg(unix)]
1650    create_process_group: bool,
1651}
1652
1653enum NativeProcessBackend {
1654    Running(NativeRunningProcess),
1655    Pty(NativePtyProcess),
1656}
1657
1658#[pyclass(name = "NativeProcess")]
1659struct PyNativeProcess {
1660    backend: NativeProcessBackend,
1661}
1662
1663#[pyclass]
1664#[derive(Clone)]
1665struct NativeSignalBool {
1666    value: Arc<AtomicBool>,
1667    write_lock: Arc<Mutex<()>>,
1668}
1669
1670struct IdleMonitorState {
1671    last_reset_at: Instant,
1672    returncode: Option<i32>,
1673    interrupted: bool,
1674}
1675
1676/// Core idle detection logic, shareable across threads via Arc.
1677/// The reader thread calls `record_output` directly without the GIL.
1678struct IdleDetectorCore {
1679    timeout_seconds: f64,
1680    stability_window_seconds: f64,
1681    sample_interval_seconds: f64,
1682    reset_on_input: bool,
1683    reset_on_output: bool,
1684    count_control_churn_as_output: bool,
1685    enabled: Arc<AtomicBool>,
1686    state: Mutex<IdleMonitorState>,
1687    condvar: Condvar,
1688}
1689
1690impl IdleDetectorCore {
1691    fn record_input(&self, byte_count: usize) {
1692        if !self.reset_on_input || byte_count == 0 {
1693            return;
1694        }
1695        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
1696        guard.last_reset_at = Instant::now();
1697        self.condvar.notify_all();
1698    }
1699
1700    fn record_output(&self, data: &[u8]) {
1701        if !self.reset_on_output || data.is_empty() {
1702            return;
1703        }
1704        let control_bytes = control_churn_bytes(data);
1705        let visible_output_bytes = data.len().saturating_sub(control_bytes);
1706        let active_output =
1707            visible_output_bytes > 0 || (self.count_control_churn_as_output && control_bytes > 0);
1708        if !active_output {
1709            return;
1710        }
1711        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
1712        guard.last_reset_at = Instant::now();
1713        self.condvar.notify_all();
1714    }
1715
1716    fn mark_exit(&self, returncode: i32, interrupted: bool) {
1717        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
1718        guard.returncode = Some(returncode);
1719        guard.interrupted = interrupted;
1720        self.condvar.notify_all();
1721    }
1722
1723    fn enabled(&self) -> bool {
1724        self.enabled.load(Ordering::Acquire)
1725    }
1726
1727    fn set_enabled(&self, enabled: bool) {
1728        let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel);
1729        if enabled && !was_enabled {
1730            let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
1731            guard.last_reset_at = Instant::now();
1732        }
1733        self.condvar.notify_all();
1734    }
1735
1736    fn wait(&self, timeout: Option<f64>) -> (bool, String, f64, Option<i32>) {
1737        let started = Instant::now();
1738        let overall_timeout = timeout.map(Duration::from_secs_f64);
1739        let min_idle = self.timeout_seconds.max(self.stability_window_seconds);
1740        let sample_interval = Duration::from_secs_f64(self.sample_interval_seconds.max(0.001));
1741
1742        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
1743        loop {
1744            let now = Instant::now();
1745            let idle_for = now.duration_since(guard.last_reset_at).as_secs_f64();
1746
1747            if let Some(returncode) = guard.returncode {
1748                let reason = if guard.interrupted {
1749                    "interrupt"
1750                } else {
1751                    "process_exit"
1752                };
1753                return (false, reason.to_string(), idle_for, Some(returncode));
1754            }
1755
1756            let enabled = self.enabled.load(Ordering::Acquire);
1757            if enabled && idle_for >= min_idle {
1758                return (true, "idle_timeout".to_string(), idle_for, None);
1759            }
1760
1761            if let Some(limit) = overall_timeout {
1762                if now.duration_since(started) >= limit {
1763                    return (false, "timeout".to_string(), idle_for, None);
1764                }
1765            }
1766
1767            let idle_remaining = if enabled {
1768                (min_idle - idle_for).max(0.0)
1769            } else {
1770                sample_interval.as_secs_f64()
1771            };
1772            let mut wait_for =
1773                sample_interval.min(Duration::from_secs_f64(idle_remaining.max(0.001)));
1774            if let Some(limit) = overall_timeout {
1775                let elapsed = now.duration_since(started);
1776                if elapsed < limit {
1777                    let remaining = limit - elapsed;
1778                    wait_for = wait_for.min(remaining);
1779                }
1780            }
1781            let result = self
1782                .condvar
1783                .wait_timeout(guard, wait_for)
1784                .expect("idle monitor mutex poisoned");
1785            guard = result.0;
1786        }
1787    }
1788}
1789
1790#[pyclass]
1791struct NativeIdleDetector {
1792    core: Arc<IdleDetectorCore>,
1793}
1794
1795struct PtyBufferState {
1796    chunks: VecDeque<Vec<u8>>,
1797    history: Vec<u8>,
1798    history_bytes: usize,
1799    closed: bool,
1800}
1801
1802#[pyclass]
1803struct NativePtyBuffer {
1804    text: bool,
1805    encoding: String,
1806    errors: String,
1807    state: Mutex<PtyBufferState>,
1808    condvar: Condvar,
1809}
1810
1811#[derive(Clone)]
1812struct TerminalInputEventRecord {
1813    data: Vec<u8>,
1814    submit: bool,
1815    shift: bool,
1816    ctrl: bool,
1817    alt: bool,
1818    virtual_key_code: u16,
1819    repeat_count: u16,
1820}
1821
1822struct TerminalInputState {
1823    events: VecDeque<TerminalInputEventRecord>,
1824    closed: bool,
1825}
1826
1827#[cfg(windows)]
1828struct ActiveTerminalInputCapture {
1829    input_handle: usize,
1830    original_mode: u32,
1831    active_mode: u32,
1832}
1833
1834#[cfg(windows)]
1835enum TerminalInputWaitOutcome {
1836    Event(TerminalInputEventRecord),
1837    Closed,
1838    Timeout,
1839}
1840
1841#[cfg(windows)]
1842fn wait_for_terminal_input_event(
1843    state: &Arc<Mutex<TerminalInputState>>,
1844    condvar: &Arc<Condvar>,
1845    timeout: Option<Duration>,
1846) -> TerminalInputWaitOutcome {
1847    let deadline = timeout.map(|limit| Instant::now() + limit);
1848    let mut guard = state.lock().expect("terminal input mutex poisoned");
1849    loop {
1850        if let Some(event) = guard.events.pop_front() {
1851            return TerminalInputWaitOutcome::Event(event);
1852        }
1853        if guard.closed {
1854            return TerminalInputWaitOutcome::Closed;
1855        }
1856        match deadline {
1857            Some(deadline) => {
1858                let now = Instant::now();
1859                if now >= deadline {
1860                    return TerminalInputWaitOutcome::Timeout;
1861                }
1862                let wait = deadline.saturating_duration_since(now);
1863                let result = condvar
1864                    .wait_timeout(guard, wait)
1865                    .expect("terminal input mutex poisoned");
1866                guard = result.0;
1867            }
1868            None => {
1869                guard = condvar.wait(guard).expect("terminal input mutex poisoned");
1870            }
1871        }
1872    }
1873}
1874
1875fn input_contains_newline(data: &[u8]) -> bool {
1876    data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
1877}
1878
1879fn record_pty_input_metrics(
1880    input_bytes_total: &Arc<AtomicUsize>,
1881    newline_events_total: &Arc<AtomicUsize>,
1882    submit_events_total: &Arc<AtomicUsize>,
1883    data: &[u8],
1884    submit: bool,
1885) {
1886    input_bytes_total.fetch_add(data.len(), Ordering::AcqRel);
1887    if input_contains_newline(data) {
1888        newline_events_total.fetch_add(1, Ordering::AcqRel);
1889    }
1890    if submit {
1891        submit_events_total.fetch_add(1, Ordering::AcqRel);
1892    }
1893}
1894
1895fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
1896    *returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
1897}
1898
1899fn poll_pty_process(
1900    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
1901    returncode: &Arc<Mutex<Option<i32>>>,
1902) -> Result<Option<i32>, std::io::Error> {
1903    let mut guard = handles.lock().expect("pty handles mutex poisoned");
1904    let Some(handles) = guard.as_mut() else {
1905        return Ok(*returncode.lock().expect("pty returncode mutex poisoned"));
1906    };
1907    let status = handles.child.try_wait()?;
1908    let code = status.map(portable_exit_code);
1909    if let Some(code) = code {
1910        store_pty_returncode(returncode, code);
1911        return Ok(Some(code));
1912    }
1913    Ok(None)
1914}
1915
1916fn write_pty_input(
1917    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
1918    data: &[u8],
1919) -> Result<(), std::io::Error> {
1920    let mut guard = handles.lock().expect("pty handles mutex poisoned");
1921    let handles = guard.as_mut().ok_or_else(|| {
1922        std::io::Error::new(
1923            std::io::ErrorKind::NotConnected,
1924            "Pseudo-terminal process is not running",
1925        )
1926    })?;
1927    #[cfg(windows)]
1928    let payload = public_symbols::rp_pty_windows_input_payload_public(data);
1929    #[cfg(unix)]
1930    let payload = pty_platform::input_payload(data);
1931    handles.writer.write_all(&payload)?;
1932    handles.writer.flush()
1933}
1934
1935#[pyclass]
1936#[derive(Clone)]
1937struct NativeTerminalInputEvent {
1938    data: Vec<u8>,
1939    submit: bool,
1940    shift: bool,
1941    ctrl: bool,
1942    alt: bool,
1943    virtual_key_code: u16,
1944    repeat_count: u16,
1945}
1946
1947#[pyclass]
1948struct NativeTerminalInput {
1949    state: Arc<Mutex<TerminalInputState>>,
1950    condvar: Arc<Condvar>,
1951    stop: Arc<AtomicBool>,
1952    capturing: Arc<AtomicBool>,
1953    worker: Mutex<Option<thread::JoinHandle<()>>>,
1954    #[cfg(windows)]
1955    console: Mutex<Option<ActiveTerminalInputCapture>>,
1956}
1957
1958#[pymethods]
1959impl NativeRunningProcess {
1960    #[new]
1961    #[allow(clippy::too_many_arguments)]
1962    #[pyo3(signature = (command, cwd=None, shell=false, capture=true, env=None, creationflags=None, text=true, encoding=None, errors=None, stdin_mode_name="inherit", stderr_mode_name="stdout", nice=None, create_process_group=false))]
1963    fn new(
1964        command: &Bound<'_, PyAny>,
1965        cwd: Option<String>,
1966        shell: bool,
1967        capture: bool,
1968        env: Option<Bound<'_, PyDict>>,
1969        creationflags: Option<u32>,
1970        text: bool,
1971        encoding: Option<String>,
1972        errors: Option<String>,
1973        stdin_mode_name: &str,
1974        stderr_mode_name: &str,
1975        nice: Option<i32>,
1976        create_process_group: bool,
1977    ) -> PyResult<Self> {
1978        let parsed = parse_command(command, shell)?;
1979        let env_pairs = env
1980            .map(|mapping| {
1981                mapping
1982                    .iter()
1983                    .map(|(key, value)| Ok((key.extract::<String>()?, value.extract::<String>()?)))
1984                    .collect::<PyResult<Vec<(String, String)>>>()
1985            })
1986            .transpose()?;
1987
1988        Ok(Self {
1989            inner: NativeProcess::new(ProcessConfig {
1990                command: parsed,
1991                cwd: cwd.map(PathBuf::from),
1992                env: env_pairs,
1993                capture,
1994                stderr_mode: stderr_mode(stderr_mode_name)?,
1995                creationflags,
1996                create_process_group,
1997                stdin_mode: stdin_mode(stdin_mode_name)?,
1998                nice,
1999                containment: None,
2000            }),
2001            text,
2002            encoding,
2003            errors,
2004            #[cfg(windows)]
2005            creationflags,
2006            #[cfg(unix)]
2007            create_process_group,
2008        })
2009    }
2010
2011    #[inline(never)]
2012    fn start(&self) -> PyResult<()> {
2013        public_symbols::rp_native_running_process_start_public(self)
2014    }
2015
2016    fn poll(&self) -> PyResult<Option<i32>> {
2017        self.inner.poll().map_err(to_py_err)
2018    }
2019
2020    #[pyo3(signature = (timeout=None))]
2021    #[inline(never)]
2022    fn wait(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<i32> {
2023        public_symbols::rp_native_running_process_wait_public(self, py, timeout)
2024    }
2025
2026    #[inline(never)]
2027    fn kill(&self) -> PyResult<()> {
2028        public_symbols::rp_native_running_process_kill_public(self)
2029    }
2030
2031    #[inline(never)]
2032    fn terminate(&self) -> PyResult<()> {
2033        public_symbols::rp_native_running_process_terminate_public(self)
2034    }
2035
2036    #[inline(never)]
2037    fn close(&self, py: Python<'_>) -> PyResult<()> {
2038        public_symbols::rp_native_running_process_close_public(self, py)
2039    }
2040
2041    fn terminate_group(&self) -> PyResult<()> {
2042        #[cfg(unix)]
2043        {
2044            let pid = self
2045                .inner
2046                .pid()
2047                .ok_or_else(|| PyRuntimeError::new_err("process is not running"))?;
2048            if self.create_process_group {
2049                unix_signal_process_group(pid as i32, UnixSignal::Terminate).map_err(to_py_err)?;
2050                return Ok(());
2051            }
2052        }
2053        self.inner.terminate().map_err(to_py_err)
2054    }
2055
2056    fn write_stdin(&self, data: &[u8]) -> PyResult<()> {
2057        self.inner.write_stdin(data).map_err(to_py_err)
2058    }
2059
2060    #[getter]
2061    fn pid(&self) -> Option<u32> {
2062        self.inner.pid()
2063    }
2064
2065    #[getter]
2066    fn returncode(&self) -> Option<i32> {
2067        self.inner.returncode()
2068    }
2069
2070    #[inline(never)]
2071    fn send_interrupt(&self) -> PyResult<()> {
2072        public_symbols::rp_native_running_process_send_interrupt_public(self)
2073    }
2074
2075    fn kill_group(&self) -> PyResult<()> {
2076        #[cfg(unix)]
2077        {
2078            let pid = self
2079                .inner
2080                .pid()
2081                .ok_or_else(|| PyRuntimeError::new_err("process is not running"))?;
2082            if self.create_process_group {
2083                unix_signal_process_group(pid as i32, UnixSignal::Kill).map_err(to_py_err)?;
2084                return Ok(());
2085            }
2086        }
2087        self.inner.kill().map_err(to_py_err)
2088    }
2089
2090    fn has_pending_combined(&self) -> bool {
2091        self.inner.has_pending_combined()
2092    }
2093
2094    fn has_pending_stream(&self, stream: &str) -> PyResult<bool> {
2095        Ok(self.inner.has_pending_stream(stream_kind(stream)?))
2096    }
2097
2098    fn drain_combined(&self, py: Python<'_>) -> PyResult<Vec<(String, Py<PyAny>)>> {
2099        self.inner
2100            .drain_combined()
2101            .into_iter()
2102            .map(|event| {
2103                Ok((
2104                    event.stream.as_str().to_string(),
2105                    self.decode_line(py, &event.line)?,
2106                ))
2107            })
2108            .collect()
2109    }
2110
2111    fn drain_stream(&self, py: Python<'_>, stream: &str) -> PyResult<Vec<Py<PyAny>>> {
2112        self.inner
2113            .drain_stream(stream_kind(stream)?)
2114            .into_iter()
2115            .map(|line| self.decode_line(py, &line))
2116            .collect()
2117    }
2118
2119    #[pyo3(signature = (timeout=None))]
2120    fn take_combined_line(
2121        &self,
2122        py: Python<'_>,
2123        timeout: Option<f64>,
2124    ) -> PyResult<(String, Option<String>, Option<Py<PyAny>>)> {
2125        match self
2126            .inner
2127            .read_combined(timeout.map(Duration::from_secs_f64))
2128        {
2129            ReadStatus::Line(StreamEvent { stream, line }) => Ok((
2130                "line".into(),
2131                Some(stream.as_str().into()),
2132                Some(self.decode_line(py, &line)?),
2133            )),
2134            ReadStatus::Timeout => Ok(("timeout".into(), None, None)),
2135            ReadStatus::Eof => Ok(("eof".into(), None, None)),
2136        }
2137    }
2138
2139    #[pyo3(signature = (stream, timeout=None))]
2140    fn take_stream_line(
2141        &self,
2142        py: Python<'_>,
2143        stream: &str,
2144        timeout: Option<f64>,
2145    ) -> PyResult<(String, Option<Py<PyAny>>)> {
2146        match self
2147            .inner
2148            .read_stream(stream_kind(stream)?, timeout.map(Duration::from_secs_f64))
2149        {
2150            ReadStatus::Line(line) => Ok(("line".into(), Some(self.decode_line(py, &line)?))),
2151            ReadStatus::Timeout => Ok(("timeout".into(), None)),
2152            ReadStatus::Eof => Ok(("eof".into(), None)),
2153        }
2154    }
2155
2156    fn captured_stdout(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
2157        self.inner
2158            .captured_stdout()
2159            .into_iter()
2160            .map(|line| self.decode_line(py, &line))
2161            .collect()
2162    }
2163
2164    fn captured_stderr(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
2165        self.inner
2166            .captured_stderr()
2167            .into_iter()
2168            .map(|line| self.decode_line(py, &line))
2169            .collect()
2170    }
2171
2172    fn captured_combined(&self, py: Python<'_>) -> PyResult<Vec<(String, Py<PyAny>)>> {
2173        self.inner
2174            .captured_combined()
2175            .into_iter()
2176            .map(|event| {
2177                Ok((
2178                    event.stream.as_str().to_string(),
2179                    self.decode_line(py, &event.line)?,
2180                ))
2181            })
2182            .collect()
2183    }
2184
2185    fn captured_stream_bytes(&self, stream: &str) -> PyResult<usize> {
2186        Ok(self.inner.captured_stream_bytes(stream_kind(stream)?))
2187    }
2188
2189    fn captured_combined_bytes(&self) -> usize {
2190        self.inner.captured_combined_bytes()
2191    }
2192
2193    fn clear_captured_stream(&self, stream: &str) -> PyResult<usize> {
2194        Ok(self.inner.clear_captured_stream(stream_kind(stream)?))
2195    }
2196
2197    fn clear_captured_combined(&self) -> usize {
2198        self.inner.clear_captured_combined()
2199    }
2200
2201    #[pyo3(signature = (stream, pattern, is_regex=false, timeout=None))]
2202    fn expect(
2203        &self,
2204        py: Python<'_>,
2205        stream: &str,
2206        pattern: &str,
2207        is_regex: bool,
2208        timeout: Option<f64>,
2209    ) -> PyResult<ExpectResult> {
2210        let stream_kind = if stream == "combined" {
2211            None
2212        } else {
2213            Some(stream_kind(stream)?)
2214        };
2215        let mut buffer = match stream_kind {
2216            Some(kind) => self.captured_stream_text(py, kind)?,
2217            None => self.captured_combined_text(py)?,
2218        };
2219        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
2220
2221        loop {
2222            if let Some((matched, start, end, groups)) =
2223                self.find_expect_match(&buffer, pattern, is_regex)?
2224            {
2225                return Ok((
2226                    "match".to_string(),
2227                    buffer,
2228                    Some(matched),
2229                    Some(start),
2230                    Some(end),
2231                    groups,
2232                ));
2233            }
2234
2235            let wait_timeout = deadline.map(|limit| {
2236                let now = Instant::now();
2237                if now >= limit {
2238                    Duration::from_secs(0)
2239                } else {
2240                    limit
2241                        .saturating_duration_since(now)
2242                        .min(Duration::from_millis(100))
2243                }
2244            });
2245            if deadline.is_some_and(|limit| Instant::now() >= limit) {
2246                return Ok(("timeout".to_string(), buffer, None, None, None, Vec::new()));
2247            }
2248
2249            match self.read_status_text(stream_kind, wait_timeout)? {
2250                ReadStatus::Line(line) => {
2251                    let decoded = self.decode_line_to_string(py, &line)?;
2252                    buffer.push_str(&decoded);
2253                    buffer.push('\n');
2254                }
2255                ReadStatus::Timeout => {
2256                    // Keep polling until the overall expect deadline expires.
2257                    continue;
2258                }
2259                ReadStatus::Eof => {
2260                    return Ok(("eof".to_string(), buffer, None, None, None, Vec::new()));
2261                }
2262            }
2263        }
2264    }
2265
2266    #[staticmethod]
2267    fn is_pty_available() -> bool {
2268        false
2269    }
2270}
2271
2272#[pymethods]
2273impl PyNativeProcess {
2274    #[new]
2275    #[allow(clippy::too_many_arguments)]
2276    #[pyo3(signature = (command, cwd=None, shell=false, capture=true, env=None, creationflags=None, text=true, encoding=None, errors=None, stdin_mode_name="inherit", stderr_mode_name="stdout", nice=None, create_process_group=false))]
2277    fn new(
2278        command: &Bound<'_, PyAny>,
2279        cwd: Option<String>,
2280        shell: bool,
2281        capture: bool,
2282        env: Option<Bound<'_, PyDict>>,
2283        creationflags: Option<u32>,
2284        text: bool,
2285        encoding: Option<String>,
2286        errors: Option<String>,
2287        stdin_mode_name: &str,
2288        stderr_mode_name: &str,
2289        nice: Option<i32>,
2290        create_process_group: bool,
2291    ) -> PyResult<Self> {
2292        Ok(Self {
2293            backend: NativeProcessBackend::Running(NativeRunningProcess::new(
2294                command,
2295                cwd,
2296                shell,
2297                capture,
2298                env,
2299                creationflags,
2300                text,
2301                encoding,
2302                errors,
2303                stdin_mode_name,
2304                stderr_mode_name,
2305                nice,
2306                create_process_group,
2307            )?),
2308        })
2309    }
2310
2311    #[staticmethod]
2312    #[pyo3(signature = (argv, cwd=None, env=None, rows=24, cols=80, nice=None))]
2313    fn for_pty(
2314        argv: Vec<String>,
2315        cwd: Option<String>,
2316        env: Option<Bound<'_, PyDict>>,
2317        rows: u16,
2318        cols: u16,
2319        nice: Option<i32>,
2320    ) -> PyResult<Self> {
2321        Ok(Self {
2322            backend: NativeProcessBackend::Pty(NativePtyProcess::new(
2323                argv, cwd, env, rows, cols, nice,
2324            )?),
2325        })
2326    }
2327
2328    fn start(&self) -> PyResult<()> {
2329        match &self.backend {
2330            NativeProcessBackend::Running(process) => process.start(),
2331            NativeProcessBackend::Pty(process) => process.start(),
2332        }
2333    }
2334
2335    fn poll(&self) -> PyResult<Option<i32>> {
2336        match &self.backend {
2337            NativeProcessBackend::Running(process) => process.poll(),
2338            NativeProcessBackend::Pty(process) => process.poll(),
2339        }
2340    }
2341
2342    #[pyo3(signature = (timeout=None))]
2343    fn wait(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<i32> {
2344        match &self.backend {
2345            NativeProcessBackend::Running(process) => process.wait(py, timeout),
2346            NativeProcessBackend::Pty(process) => py.allow_threads(|| process.wait(timeout)),
2347        }
2348    }
2349
2350    fn kill(&self) -> PyResult<()> {
2351        match &self.backend {
2352            NativeProcessBackend::Running(process) => process.kill(),
2353            NativeProcessBackend::Pty(process) => process.kill(),
2354        }
2355    }
2356
2357    fn terminate(&self) -> PyResult<()> {
2358        match &self.backend {
2359            NativeProcessBackend::Running(process) => process.terminate(),
2360            NativeProcessBackend::Pty(process) => process.terminate(),
2361        }
2362    }
2363
2364    fn terminate_group(&self) -> PyResult<()> {
2365        match &self.backend {
2366            NativeProcessBackend::Running(process) => process.terminate_group(),
2367            NativeProcessBackend::Pty(process) => process.terminate_tree(),
2368        }
2369    }
2370
2371    fn kill_group(&self) -> PyResult<()> {
2372        match &self.backend {
2373            NativeProcessBackend::Running(process) => process.kill_group(),
2374            NativeProcessBackend::Pty(process) => process.kill_tree(),
2375        }
2376    }
2377
2378    fn has_pending_combined(&self) -> PyResult<bool> {
2379        match &self.backend {
2380            NativeProcessBackend::Running(process) => Ok(process.has_pending_combined()),
2381            NativeProcessBackend::Pty(_) => Ok(false),
2382        }
2383    }
2384
2385    fn has_pending_stream(&self, stream: &str) -> PyResult<bool> {
2386        match &self.backend {
2387            NativeProcessBackend::Running(process) => process.has_pending_stream(stream),
2388            NativeProcessBackend::Pty(_) => Ok(false),
2389        }
2390    }
2391
2392    fn drain_combined(&self, py: Python<'_>) -> PyResult<Vec<(String, Py<PyAny>)>> {
2393        match &self.backend {
2394            NativeProcessBackend::Running(process) => process.drain_combined(py),
2395            NativeProcessBackend::Pty(_) => Ok(Vec::new()),
2396        }
2397    }
2398
2399    fn drain_stream(&self, py: Python<'_>, stream: &str) -> PyResult<Vec<Py<PyAny>>> {
2400        match &self.backend {
2401            NativeProcessBackend::Running(process) => process.drain_stream(py, stream),
2402            NativeProcessBackend::Pty(_) => {
2403                let _ = stream;
2404                Ok(Vec::new())
2405            }
2406        }
2407    }
2408
2409    #[pyo3(signature = (timeout=None))]
2410    fn take_combined_line(
2411        &self,
2412        py: Python<'_>,
2413        timeout: Option<f64>,
2414    ) -> PyResult<(String, Option<String>, Option<Py<PyAny>>)> {
2415        match &self.backend {
2416            NativeProcessBackend::Running(process) => process.take_combined_line(py, timeout),
2417            NativeProcessBackend::Pty(_) => Ok(("eof".into(), None, None)),
2418        }
2419    }
2420
2421    #[pyo3(signature = (stream, timeout=None))]
2422    fn take_stream_line(
2423        &self,
2424        py: Python<'_>,
2425        stream: &str,
2426        timeout: Option<f64>,
2427    ) -> PyResult<(String, Option<Py<PyAny>>)> {
2428        match &self.backend {
2429            NativeProcessBackend::Running(process) => process.take_stream_line(py, stream, timeout),
2430            NativeProcessBackend::Pty(_) => {
2431                let _ = (py, stream, timeout);
2432                Ok(("eof".into(), None))
2433            }
2434        }
2435    }
2436
2437    fn captured_stdout(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
2438        match &self.backend {
2439            NativeProcessBackend::Running(process) => process.captured_stdout(py),
2440            NativeProcessBackend::Pty(_) => Ok(Vec::new()),
2441        }
2442    }
2443
2444    fn captured_stderr(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
2445        match &self.backend {
2446            NativeProcessBackend::Running(process) => process.captured_stderr(py),
2447            NativeProcessBackend::Pty(_) => Ok(Vec::new()),
2448        }
2449    }
2450
2451    fn captured_combined(&self, py: Python<'_>) -> PyResult<Vec<(String, Py<PyAny>)>> {
2452        match &self.backend {
2453            NativeProcessBackend::Running(process) => process.captured_combined(py),
2454            NativeProcessBackend::Pty(_) => Ok(Vec::new()),
2455        }
2456    }
2457
2458    fn captured_stream_bytes(&self, stream: &str) -> PyResult<usize> {
2459        match &self.backend {
2460            NativeProcessBackend::Running(process) => process.captured_stream_bytes(stream),
2461            NativeProcessBackend::Pty(_) => Ok(0),
2462        }
2463    }
2464
2465    fn captured_combined_bytes(&self) -> PyResult<usize> {
2466        match &self.backend {
2467            NativeProcessBackend::Running(process) => Ok(process.captured_combined_bytes()),
2468            NativeProcessBackend::Pty(_) => Ok(0),
2469        }
2470    }
2471
2472    fn clear_captured_stream(&self, stream: &str) -> PyResult<usize> {
2473        match &self.backend {
2474            NativeProcessBackend::Running(process) => process.clear_captured_stream(stream),
2475            NativeProcessBackend::Pty(_) => Ok(0),
2476        }
2477    }
2478
2479    fn clear_captured_combined(&self) -> PyResult<usize> {
2480        match &self.backend {
2481            NativeProcessBackend::Running(process) => Ok(process.clear_captured_combined()),
2482            NativeProcessBackend::Pty(_) => Ok(0),
2483        }
2484    }
2485
2486    fn write_stdin(&self, data: &[u8]) -> PyResult<()> {
2487        match &self.backend {
2488            NativeProcessBackend::Running(process) => process.write_stdin(data),
2489            NativeProcessBackend::Pty(process) => process.write(data, false),
2490        }
2491    }
2492
2493    #[pyo3(signature = (data, submit=false))]
2494    fn write(&self, data: &[u8], submit: bool) -> PyResult<()> {
2495        match &self.backend {
2496            NativeProcessBackend::Running(process) => process.write_stdin(data),
2497            NativeProcessBackend::Pty(process) => process.write(data, submit),
2498        }
2499    }
2500
2501    #[pyo3(signature = (timeout=None))]
2502    fn read_chunk(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<Py<PyAny>> {
2503        match &self.backend {
2504            NativeProcessBackend::Pty(process) => process.read_chunk(py, timeout),
2505            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2506                "read_chunk is only available for PTY-backed NativeProcess",
2507            )),
2508        }
2509    }
2510
2511    #[pyo3(signature = (timeout=None))]
2512    fn wait_for_pty_reader_closed(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<bool> {
2513        match &self.backend {
2514            NativeProcessBackend::Pty(process) => process.wait_for_reader_closed(py, timeout),
2515            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2516                "wait_for_pty_reader_closed is only available for PTY-backed NativeProcess",
2517            )),
2518        }
2519    }
2520
2521    fn respond_to_queries(&self, data: &[u8]) -> PyResult<()> {
2522        match &self.backend {
2523            NativeProcessBackend::Pty(process) => process.respond_to_queries(data),
2524            NativeProcessBackend::Running(_) => Ok(()),
2525        }
2526    }
2527
2528    fn resize(&self, rows: u16, cols: u16) -> PyResult<()> {
2529        match &self.backend {
2530            NativeProcessBackend::Pty(process) => process.resize(rows, cols),
2531            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2532                "resize is only available for PTY-backed NativeProcess",
2533            )),
2534        }
2535    }
2536
2537    fn send_interrupt(&self) -> PyResult<()> {
2538        match &self.backend {
2539            NativeProcessBackend::Running(process) => process.send_interrupt(),
2540            NativeProcessBackend::Pty(process) => process.send_interrupt(),
2541        }
2542    }
2543
2544    #[pyo3(signature = (stream, pattern, is_regex=false, timeout=None))]
2545    fn expect(
2546        &self,
2547        py: Python<'_>,
2548        stream: &str,
2549        pattern: &str,
2550        is_regex: bool,
2551        timeout: Option<f64>,
2552    ) -> PyResult<ExpectResult> {
2553        match &self.backend {
2554            NativeProcessBackend::Running(process) => {
2555                process.expect(py, stream, pattern, is_regex, timeout)
2556            }
2557            NativeProcessBackend::Pty(_) => Err(PyRuntimeError::new_err(
2558                "expect is only available for subprocess-backed NativeProcess",
2559            )),
2560        }
2561    }
2562
2563    fn close(&self, py: Python<'_>) -> PyResult<()> {
2564        match &self.backend {
2565            NativeProcessBackend::Running(process) => process.close(py),
2566            NativeProcessBackend::Pty(process) => process.close(py),
2567        }
2568    }
2569
2570    fn start_terminal_input_relay(&self) -> PyResult<()> {
2571        match &self.backend {
2572            NativeProcessBackend::Pty(process) => process.start_terminal_input_relay(),
2573            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2574                "terminal input relay is only available for PTY-backed NativeProcess",
2575            )),
2576        }
2577    }
2578
2579    fn stop_terminal_input_relay(&self) -> PyResult<()> {
2580        match &self.backend {
2581            NativeProcessBackend::Pty(process) => {
2582                process.stop_terminal_input_relay();
2583                Ok(())
2584            }
2585            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2586                "terminal input relay is only available for PTY-backed NativeProcess",
2587            )),
2588        }
2589    }
2590
2591    fn terminal_input_relay_active(&self) -> PyResult<bool> {
2592        match &self.backend {
2593            NativeProcessBackend::Pty(process) => Ok(process.terminal_input_relay_active()),
2594            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2595                "terminal input relay is only available for PTY-backed NativeProcess",
2596            )),
2597        }
2598    }
2599
2600    fn pty_input_bytes_total(&self) -> PyResult<usize> {
2601        match &self.backend {
2602            NativeProcessBackend::Pty(process) => Ok(process.pty_input_bytes_total()),
2603            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2604                "PTY input metrics are only available for PTY-backed NativeProcess",
2605            )),
2606        }
2607    }
2608
2609    fn pty_newline_events_total(&self) -> PyResult<usize> {
2610        match &self.backend {
2611            NativeProcessBackend::Pty(process) => Ok(process.pty_newline_events_total()),
2612            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2613                "PTY input metrics are only available for PTY-backed NativeProcess",
2614            )),
2615        }
2616    }
2617
2618    fn pty_submit_events_total(&self) -> PyResult<usize> {
2619        match &self.backend {
2620            NativeProcessBackend::Pty(process) => Ok(process.pty_submit_events_total()),
2621            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2622                "PTY input metrics are only available for PTY-backed NativeProcess",
2623            )),
2624        }
2625    }
2626
2627    #[getter]
2628    fn pid(&self) -> PyResult<Option<u32>> {
2629        match &self.backend {
2630            NativeProcessBackend::Running(process) => Ok(process.pid()),
2631            NativeProcessBackend::Pty(process) => process.pid(),
2632        }
2633    }
2634
2635    #[getter]
2636    fn returncode(&self) -> PyResult<Option<i32>> {
2637        match &self.backend {
2638            NativeProcessBackend::Running(process) => Ok(process.returncode()),
2639            NativeProcessBackend::Pty(process) => Ok(*process
2640                .returncode
2641                .lock()
2642                .expect("pty returncode mutex poisoned")),
2643        }
2644    }
2645
2646    fn is_pty(&self) -> bool {
2647        matches!(self.backend, NativeProcessBackend::Pty(_))
2648    }
2649
2650    /// Wait for exit then drain remaining output (PTY only).
2651    #[pyo3(signature = (timeout=None, drain_timeout=2.0))]
2652    fn wait_and_drain(
2653        &self,
2654        py: Python<'_>,
2655        timeout: Option<f64>,
2656        drain_timeout: f64,
2657    ) -> PyResult<i32> {
2658        match &self.backend {
2659            NativeProcessBackend::Pty(process) => {
2660                process.wait_and_drain(py, timeout, drain_timeout)
2661            }
2662            NativeProcessBackend::Running(_) => Err(PyRuntimeError::new_err(
2663                "wait_and_drain is only available for PTY-backed NativeProcess",
2664            )),
2665        }
2666    }
2667}
2668
2669#[pymethods]
2670impl NativePtyProcess {
2671    #[new]
2672    #[pyo3(signature = (argv, cwd=None, env=None, rows=24, cols=80, nice=None))]
2673    fn new(
2674        argv: Vec<String>,
2675        cwd: Option<String>,
2676        env: Option<Bound<'_, PyDict>>,
2677        rows: u16,
2678        cols: u16,
2679        nice: Option<i32>,
2680    ) -> PyResult<Self> {
2681        if argv.is_empty() {
2682            return Err(PyValueError::new_err("command cannot be empty"));
2683        }
2684        #[cfg(not(windows))]
2685        let _ = nice;
2686        let env_pairs = env
2687            .map(|mapping| {
2688                mapping
2689                    .iter()
2690                    .map(|(key, value)| Ok((key.extract::<String>()?, value.extract::<String>()?)))
2691                    .collect::<PyResult<Vec<(String, String)>>>()
2692            })
2693            .transpose()?;
2694        Ok(Self {
2695            argv,
2696            cwd,
2697            env: env_pairs,
2698            rows,
2699            cols,
2700            #[cfg(windows)]
2701            nice,
2702            handles: Arc::new(Mutex::new(None)),
2703            reader: Arc::new(PtyReadShared {
2704                state: Mutex::new(PtyReadState {
2705                    chunks: VecDeque::new(),
2706                    closed: false,
2707                }),
2708                condvar: Condvar::new(),
2709            }),
2710            returncode: Arc::new(Mutex::new(None)),
2711            input_bytes_total: Arc::new(AtomicUsize::new(0)),
2712            newline_events_total: Arc::new(AtomicUsize::new(0)),
2713            submit_events_total: Arc::new(AtomicUsize::new(0)),
2714            echo: Arc::new(AtomicBool::new(false)),
2715            idle_detector: Arc::new(Mutex::new(None)),
2716            output_bytes_total: Arc::new(AtomicUsize::new(0)),
2717            control_churn_bytes_total: Arc::new(AtomicUsize::new(0)),
2718            #[cfg(windows)]
2719            terminal_input_relay_stop: Arc::new(AtomicBool::new(false)),
2720            #[cfg(windows)]
2721            terminal_input_relay_active: Arc::new(AtomicBool::new(false)),
2722            #[cfg(windows)]
2723            terminal_input_relay_worker: Mutex::new(None),
2724        })
2725    }
2726
2727    #[inline(never)]
2728    fn start(&self) -> PyResult<()> {
2729        public_symbols::rp_native_pty_process_start_public(self)
2730    }
2731
2732    #[pyo3(signature = (timeout=None))]
2733    fn read_chunk(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<Py<PyAny>> {
2734        // Wait for a chunk WITHOUT holding the GIL. The previous version
2735        // called `condvar.wait()` while still holding the GIL, which starved
2736        // every other Python thread for the duration of the wait. With a
2737        // 100ms read poll loop, that meant the main thread could only run
2738        // for a few microseconds every 100ms — turning ordinary calls like
2739        // `os.path.realpath` into ~430ms operations and producing apparent
2740        // deadlocks during pytest failure formatting.
2741        enum WaitOutcome {
2742            Chunk(Vec<u8>),
2743            Closed,
2744            Timeout,
2745        }
2746
2747        let outcome = py.allow_threads(|| -> WaitOutcome {
2748            let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
2749            let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
2750            loop {
2751                if let Some(chunk) = guard.chunks.pop_front() {
2752                    return WaitOutcome::Chunk(chunk);
2753                }
2754                if guard.closed {
2755                    return WaitOutcome::Closed;
2756                }
2757                match deadline {
2758                    Some(deadline) => {
2759                        let now = Instant::now();
2760                        if now >= deadline {
2761                            return WaitOutcome::Timeout;
2762                        }
2763                        let wait = deadline.saturating_duration_since(now);
2764                        let result = self
2765                            .reader
2766                            .condvar
2767                            .wait_timeout(guard, wait)
2768                            .expect("pty read mutex poisoned");
2769                        guard = result.0;
2770                    }
2771                    None => {
2772                        guard = self
2773                            .reader
2774                            .condvar
2775                            .wait(guard)
2776                            .expect("pty read mutex poisoned");
2777                    }
2778                }
2779            }
2780        });
2781
2782        match outcome {
2783            WaitOutcome::Chunk(chunk) => Ok(PyBytes::new(py, &chunk).into_any().unbind()),
2784            WaitOutcome::Closed => Err(PyRuntimeError::new_err("Pseudo-terminal stream is closed")),
2785            WaitOutcome::Timeout => Err(PyTimeoutError::new_err(
2786                "No pseudo-terminal output available before timeout",
2787            )),
2788        }
2789    }
2790
2791    #[pyo3(signature = (timeout=None))]
2792    fn wait_for_reader_closed(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<bool> {
2793        let closed = py.allow_threads(|| {
2794            let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
2795            let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
2796            loop {
2797                if guard.closed {
2798                    return true;
2799                }
2800                match deadline {
2801                    Some(deadline) => {
2802                        let now = Instant::now();
2803                        if now >= deadline {
2804                            return false;
2805                        }
2806                        let wait = deadline.saturating_duration_since(now);
2807                        let result = self
2808                            .reader
2809                            .condvar
2810                            .wait_timeout(guard, wait)
2811                            .expect("pty read mutex poisoned");
2812                        guard = result.0;
2813                    }
2814                    None => {
2815                        guard = self
2816                            .reader
2817                            .condvar
2818                            .wait(guard)
2819                            .expect("pty read mutex poisoned");
2820                    }
2821                }
2822            }
2823        });
2824        Ok(closed)
2825    }
2826
2827    #[pyo3(signature = (data, submit=false))]
2828    fn write(&self, data: &[u8], submit: bool) -> PyResult<()> {
2829        self.write_impl(data, submit)
2830    }
2831
2832    fn respond_to_queries(&self, data: &[u8]) -> PyResult<()> {
2833        public_symbols::rp_native_pty_process_respond_to_queries_public(self, data)
2834    }
2835
2836    #[inline(never)]
2837    fn resize(&self, rows: u16, cols: u16) -> PyResult<()> {
2838        public_symbols::rp_native_pty_process_resize_public(self, rows, cols)
2839    }
2840
2841    #[inline(never)]
2842    fn send_interrupt(&self) -> PyResult<()> {
2843        public_symbols::rp_native_pty_process_send_interrupt_public(self)
2844    }
2845
2846    fn poll(&self) -> PyResult<Option<i32>> {
2847        poll_pty_process(&self.handles, &self.returncode).map_err(to_py_err)
2848    }
2849
2850    #[pyo3(signature = (timeout=None))]
2851    #[inline(never)]
2852    fn wait(&self, timeout: Option<f64>) -> PyResult<i32> {
2853        public_symbols::rp_native_pty_process_wait_public(self, timeout)
2854    }
2855
2856    #[inline(never)]
2857    fn terminate(&self) -> PyResult<()> {
2858        public_symbols::rp_native_pty_process_terminate_public(self)
2859    }
2860
2861    #[inline(never)]
2862    fn kill(&self) -> PyResult<()> {
2863        public_symbols::rp_native_pty_process_kill_public(self)
2864    }
2865
2866    #[inline(never)]
2867    fn terminate_tree(&self) -> PyResult<()> {
2868        public_symbols::rp_native_pty_process_terminate_tree_public(self)
2869    }
2870
2871    #[inline(never)]
2872    fn kill_tree(&self) -> PyResult<()> {
2873        public_symbols::rp_native_pty_process_kill_tree_public(self)
2874    }
2875
2876    fn start_terminal_input_relay(&self) -> PyResult<()> {
2877        self.start_terminal_input_relay_impl()
2878    }
2879
2880    fn stop_terminal_input_relay(&self) {
2881        self.stop_terminal_input_relay_impl();
2882    }
2883
2884    fn terminal_input_relay_active(&self) -> bool {
2885        #[cfg(windows)]
2886        {
2887            self.terminal_input_relay_active.load(Ordering::Acquire)
2888        }
2889
2890        #[cfg(not(windows))]
2891        {
2892            false
2893        }
2894    }
2895
2896    fn pty_input_bytes_total(&self) -> usize {
2897        self.input_bytes_total.load(Ordering::Acquire)
2898    }
2899
2900    fn pty_newline_events_total(&self) -> usize {
2901        self.newline_events_total.load(Ordering::Acquire)
2902    }
2903
2904    fn pty_submit_events_total(&self) -> usize {
2905        self.submit_events_total.load(Ordering::Acquire)
2906    }
2907
2908    /// Visible (non-control) output bytes tracked by the reader thread.
2909    fn pty_output_bytes_total(&self) -> usize {
2910        self.output_bytes_total.load(Ordering::Acquire)
2911    }
2912
2913    /// Control churn bytes (ANSI escapes, BS, CR, DEL) tracked by the reader thread.
2914    fn pty_control_churn_bytes_total(&self) -> usize {
2915        self.control_churn_bytes_total.load(Ordering::Acquire)
2916    }
2917
2918    /// Wait for exit then drain remaining output.  Entire operation runs
2919    /// in Rust with the GIL released.
2920    #[pyo3(signature = (timeout=None, drain_timeout=2.0))]
2921    fn wait_and_drain(
2922        &self,
2923        py: Python<'_>,
2924        timeout: Option<f64>,
2925        drain_timeout: f64,
2926    ) -> PyResult<i32> {
2927        py.allow_threads(|| {
2928            // Wait for exit.
2929            let code = self.wait_impl(timeout)?;
2930            // Drain: wait for reader thread to close.
2931            let deadline = Instant::now() + Duration::from_secs_f64(drain_timeout.max(0.0));
2932            let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
2933            while !guard.closed {
2934                let remaining = deadline.saturating_duration_since(Instant::now());
2935                if remaining.is_zero() {
2936                    break;
2937                }
2938                let result = self
2939                    .reader
2940                    .condvar
2941                    .wait_timeout(guard, remaining)
2942                    .expect("pty read mutex poisoned");
2943                guard = result.0;
2944            }
2945            Ok(code)
2946        })
2947    }
2948
2949    /// Enable/disable echoing PTY output to stdout from the reader thread.
2950    fn set_echo(&self, enabled: bool) {
2951        self.echo.store(enabled, Ordering::Release);
2952    }
2953
2954    fn echo_enabled(&self) -> bool {
2955        self.echo.load(Ordering::Acquire)
2956    }
2957
2958    /// Attach an idle detector so the reader thread feeds it directly.
2959    fn attach_idle_detector(&self, detector: &NativeIdleDetector) {
2960        let mut guard = self
2961            .idle_detector
2962            .lock()
2963            .expect("idle detector mutex poisoned");
2964        *guard = Some(Arc::clone(&detector.core));
2965    }
2966
2967    /// Detach the idle detector from the reader thread.
2968    fn detach_idle_detector(&self) {
2969        let mut guard = self
2970            .idle_detector
2971            .lock()
2972            .expect("idle detector mutex poisoned");
2973        *guard = None;
2974    }
2975
2976    /// Wait for idle entirely in Rust.  The reader thread feeds the
2977    /// detector directly — no Python pumping needed.
2978    #[pyo3(signature = (detector, timeout=None))]
2979    fn wait_for_idle(
2980        &self,
2981        py: Python<'_>,
2982        detector: &NativeIdleDetector,
2983        timeout: Option<f64>,
2984    ) -> PyResult<(bool, String, f64, Option<i32>)> {
2985        // Wire the detector into the reader thread.
2986        {
2987            let mut guard = self
2988                .idle_detector
2989                .lock()
2990                .expect("idle detector mutex poisoned");
2991            *guard = Some(Arc::clone(&detector.core));
2992        }
2993
2994        // Spawn exit watcher that marks the detector on process exit.
2995        let handles = Arc::clone(&self.handles);
2996        let returncode = Arc::clone(&self.returncode);
2997        let core = Arc::clone(&detector.core);
2998        let exit_watcher = thread::spawn(move || loop {
2999            match poll_pty_process(&handles, &returncode) {
3000                Ok(Some(code)) => {
3001                    // Heuristic: codes typically used for keyboard interrupt
3002                    let interrupted = code == -2 || code == 130;
3003                    core.mark_exit(code, interrupted);
3004                    return;
3005                }
3006                Ok(None) => {}
3007                Err(_) => return,
3008            }
3009            thread::sleep(Duration::from_millis(1));
3010        });
3011
3012        // Block in Rust (GIL released) until idle, exit, or timeout.
3013        let result = py.allow_threads(|| detector.core.wait(timeout));
3014
3015        // Detach detector from reader thread.
3016        {
3017            let mut guard = self
3018                .idle_detector
3019                .lock()
3020                .expect("idle detector mutex poisoned");
3021            *guard = None;
3022        }
3023        let _ = exit_watcher.join();
3024        Ok(result)
3025    }
3026
3027    #[getter]
3028    fn pid(&self) -> PyResult<Option<u32>> {
3029        let guard = self.handles.lock().expect("pty handles mutex poisoned");
3030        if let Some(handles) = guard.as_ref() {
3031            #[cfg(unix)]
3032            if let Some(pid) = handles.master.process_group_leader() {
3033                if let Ok(pid) = u32::try_from(pid) {
3034                    return Ok(Some(pid));
3035                }
3036            }
3037            return Ok(handles.child.process_id());
3038        }
3039        Ok(None)
3040    }
3041
3042    fn close(&self, py: Python<'_>) -> PyResult<()> {
3043        // Release the GIL while waiting on the child — otherwise the wait
3044        // blocks every other Python thread (including the one that may need
3045        // to drop additional references for the child to actually exit).
3046        public_symbols::rp_native_pty_process_close_public(self, py)
3047    }
3048}
3049
3050impl Drop for NativePtyProcess {
3051    fn drop(&mut self) {
3052        // Drop runs under the GIL during PyO3 finalization. Calling
3053        // `close_impl` here would block the interpreter on `child.wait()`
3054        // and deadlock with the background reader thread. Use the
3055        // non-blocking teardown instead.
3056        public_symbols::rp_native_pty_process_close_nonblocking_public(self);
3057    }
3058}
3059
3060#[pymethods]
3061impl NativeSignalBool {
3062    #[new]
3063    #[pyo3(signature = (value=false))]
3064    fn new(value: bool) -> Self {
3065        Self {
3066            value: Arc::new(AtomicBool::new(value)),
3067            write_lock: Arc::new(Mutex::new(())),
3068        }
3069    }
3070
3071    #[getter]
3072    fn value(&self) -> bool {
3073        self.load_nolock()
3074    }
3075
3076    #[setter]
3077    fn set_value(&self, value: bool) {
3078        self.store_locked(value);
3079    }
3080
3081    fn load_nolock(&self) -> bool {
3082        self.value.load(Ordering::Acquire)
3083    }
3084
3085    fn store_locked(&self, value: bool) {
3086        let _guard = self.write_lock.lock().expect("signal bool mutex poisoned");
3087        self.value.store(value, Ordering::Release);
3088    }
3089
3090    fn compare_and_swap_locked(&self, current: bool, new: bool) -> bool {
3091        let _guard = self.write_lock.lock().expect("signal bool mutex poisoned");
3092        self.value
3093            .compare_exchange(current, new, Ordering::AcqRel, Ordering::Acquire)
3094            .is_ok()
3095    }
3096}
3097
3098#[pymethods]
3099impl NativePtyBuffer {
3100    #[new]
3101    #[pyo3(signature = (text=false, encoding="utf-8", errors="replace"))]
3102    fn new(text: bool, encoding: &str, errors: &str) -> Self {
3103        Self {
3104            text,
3105            encoding: encoding.to_string(),
3106            errors: errors.to_string(),
3107            state: Mutex::new(PtyBufferState {
3108                chunks: VecDeque::new(),
3109                history: Vec::new(),
3110                history_bytes: 0,
3111                closed: false,
3112            }),
3113            condvar: Condvar::new(),
3114        }
3115    }
3116
3117    fn available(&self) -> bool {
3118        !self
3119            .state
3120            .lock()
3121            .expect("pty buffer mutex poisoned")
3122            .chunks
3123            .is_empty()
3124    }
3125
3126    fn record_output(&self, data: &[u8]) {
3127        let mut guard = self.state.lock().expect("pty buffer mutex poisoned");
3128        guard.history_bytes += data.len();
3129        guard.history.extend_from_slice(data);
3130        guard.chunks.push_back(data.to_vec());
3131        self.condvar.notify_all();
3132    }
3133
3134    fn close(&self) {
3135        let mut guard = self.state.lock().expect("pty buffer mutex poisoned");
3136        guard.closed = true;
3137        self.condvar.notify_all();
3138    }
3139
3140    #[pyo3(signature = (timeout=None))]
3141    fn read(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<Py<PyAny>> {
3142        // Mirror NativePtyProcess::read_chunk: do the wait WITHOUT the GIL
3143        // so other Python threads (notably the test/main thread) can make
3144        // progress instead of being starved by our 100ms read poll loop.
3145        enum WaitOutcome {
3146            Chunk(Vec<u8>),
3147            Closed,
3148            Timeout,
3149        }
3150
3151        let outcome = py.allow_threads(|| -> WaitOutcome {
3152            let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
3153            let mut guard = self.state.lock().expect("pty buffer mutex poisoned");
3154            loop {
3155                if let Some(chunk) = guard.chunks.pop_front() {
3156                    return WaitOutcome::Chunk(chunk);
3157                }
3158                if guard.closed {
3159                    return WaitOutcome::Closed;
3160                }
3161                match deadline {
3162                    Some(deadline) => {
3163                        let now = Instant::now();
3164                        if now >= deadline {
3165                            return WaitOutcome::Timeout;
3166                        }
3167                        let wait = deadline.saturating_duration_since(now);
3168                        let result = self
3169                            .condvar
3170                            .wait_timeout(guard, wait)
3171                            .expect("pty buffer mutex poisoned");
3172                        guard = result.0;
3173                    }
3174                    None => {
3175                        guard = self.condvar.wait(guard).expect("pty buffer mutex poisoned");
3176                    }
3177                }
3178            }
3179        });
3180
3181        match outcome {
3182            WaitOutcome::Chunk(chunk) => self.decode_chunk(py, &chunk),
3183            WaitOutcome::Closed => Err(PyRuntimeError::new_err("Pseudo-terminal stream is closed")),
3184            WaitOutcome::Timeout => Err(PyTimeoutError::new_err(
3185                "No pseudo-terminal output available before timeout",
3186            )),
3187        }
3188    }
3189
3190    fn read_non_blocking(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
3191        let mut guard = self.state.lock().expect("pty buffer mutex poisoned");
3192        if let Some(chunk) = guard.chunks.pop_front() {
3193            return self.decode_chunk(py, &chunk).map(Some);
3194        }
3195        if guard.closed {
3196            return Err(PyRuntimeError::new_err("Pseudo-terminal stream is closed"));
3197        }
3198        Ok(None)
3199    }
3200
3201    fn drain(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
3202        let mut guard = self.state.lock().expect("pty buffer mutex poisoned");
3203        guard
3204            .chunks
3205            .drain(..)
3206            .map(|chunk| self.decode_chunk(py, &chunk))
3207            .collect()
3208    }
3209
3210    fn output(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
3211        let guard = self.state.lock().expect("pty buffer mutex poisoned");
3212        self.decode_chunk(py, &guard.history)
3213    }
3214
3215    fn output_since(&self, py: Python<'_>, start: usize) -> PyResult<Py<PyAny>> {
3216        let guard = self.state.lock().expect("pty buffer mutex poisoned");
3217        let start = start.min(guard.history.len());
3218        self.decode_chunk(py, &guard.history[start..])
3219    }
3220
3221    fn history_bytes(&self) -> usize {
3222        self.state
3223            .lock()
3224            .expect("pty buffer mutex poisoned")
3225            .history_bytes
3226    }
3227
3228    fn clear_history(&self) -> usize {
3229        let mut guard = self.state.lock().expect("pty buffer mutex poisoned");
3230        let released = guard.history_bytes;
3231        guard.history.clear();
3232        guard.history_bytes = 0;
3233        released
3234    }
3235}
3236
3237impl NativeTerminalInput {
3238    fn next_event(&self) -> Option<TerminalInputEventRecord> {
3239        self.state
3240            .lock()
3241            .expect("terminal input mutex poisoned")
3242            .events
3243            .pop_front()
3244    }
3245
3246    fn event_to_py(
3247        py: Python<'_>,
3248        event: TerminalInputEventRecord,
3249    ) -> PyResult<Py<NativeTerminalInputEvent>> {
3250        Py::new(
3251            py,
3252            NativeTerminalInputEvent {
3253                data: event.data,
3254                submit: event.submit,
3255                shift: event.shift,
3256                ctrl: event.ctrl,
3257                alt: event.alt,
3258                virtual_key_code: event.virtual_key_code,
3259                repeat_count: event.repeat_count,
3260            },
3261        )
3262    }
3263
3264    fn wait_for_event(
3265        &self,
3266        py: Python<'_>,
3267        timeout: Option<f64>,
3268    ) -> PyResult<TerminalInputEventRecord> {
3269        enum WaitOutcome {
3270            Event(TerminalInputEventRecord),
3271            Closed,
3272            Timeout,
3273        }
3274
3275        let state = Arc::clone(&self.state);
3276        let condvar = Arc::clone(&self.condvar);
3277        let outcome = py.allow_threads(move || -> WaitOutcome {
3278            let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
3279            let mut guard = state.lock().expect("terminal input mutex poisoned");
3280            loop {
3281                if let Some(event) = guard.events.pop_front() {
3282                    return WaitOutcome::Event(event);
3283                }
3284                if guard.closed {
3285                    return WaitOutcome::Closed;
3286                }
3287                match deadline {
3288                    Some(deadline) => {
3289                        let now = Instant::now();
3290                        if now >= deadline {
3291                            return WaitOutcome::Timeout;
3292                        }
3293                        let wait = deadline.saturating_duration_since(now);
3294                        let result = condvar
3295                            .wait_timeout(guard, wait)
3296                            .expect("terminal input mutex poisoned");
3297                        guard = result.0;
3298                    }
3299                    None => {
3300                        guard = condvar.wait(guard).expect("terminal input mutex poisoned");
3301                    }
3302                }
3303            }
3304        });
3305
3306        match outcome {
3307            WaitOutcome::Event(event) => Ok(event),
3308            WaitOutcome::Closed => Err(PyRuntimeError::new_err("Native terminal input is closed")),
3309            WaitOutcome::Timeout => Err(PyTimeoutError::new_err(
3310                "No terminal input available before timeout",
3311            )),
3312        }
3313    }
3314
3315    fn stop_impl(&self) -> PyResult<()> {
3316        self.stop.store(true, Ordering::Release);
3317        #[cfg(windows)]
3318        append_native_terminal_input_trace_line(&format!(
3319            "[{:.6}] native_terminal_input stop_requested",
3320            unix_now_seconds(),
3321        ));
3322        if let Some(worker) = self
3323            .worker
3324            .lock()
3325            .expect("terminal input worker mutex poisoned")
3326            .take()
3327        {
3328            let _ = worker.join();
3329        }
3330        self.capturing.store(false, Ordering::Release);
3331
3332        #[cfg(windows)]
3333        let restore_result = {
3334            use winapi::um::consoleapi::SetConsoleMode;
3335            use winapi::um::winnt::HANDLE;
3336
3337            let console = self
3338                .console
3339                .lock()
3340                .expect("terminal input console mutex poisoned")
3341                .take();
3342            console.map(|capture| unsafe {
3343                SetConsoleMode(capture.input_handle as HANDLE, capture.original_mode)
3344            })
3345        };
3346
3347        let mut guard = self.state.lock().expect("terminal input mutex poisoned");
3348        guard.closed = true;
3349        self.condvar.notify_all();
3350        drop(guard);
3351
3352        #[cfg(windows)]
3353        if let Some(result) = restore_result {
3354            if result == 0 {
3355                return Err(to_py_err(std::io::Error::last_os_error()));
3356            }
3357        }
3358        Ok(())
3359    }
3360
3361    #[cfg(windows)]
3362    fn start_impl(&self) -> PyResult<()> {
3363        use winapi::um::consoleapi::{GetConsoleMode, SetConsoleMode};
3364        use winapi::um::handleapi::INVALID_HANDLE_VALUE;
3365        use winapi::um::processenv::GetStdHandle;
3366        use winapi::um::winbase::STD_INPUT_HANDLE;
3367
3368        let mut worker_guard = self
3369            .worker
3370            .lock()
3371            .expect("terminal input worker mutex poisoned");
3372        if worker_guard.is_some() {
3373            return Ok(());
3374        }
3375
3376        let input_handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
3377        if input_handle.is_null() || input_handle == INVALID_HANDLE_VALUE {
3378            return Err(to_py_err(std::io::Error::last_os_error()));
3379        }
3380
3381        let mut original_mode = 0u32;
3382        let got_mode = unsafe { GetConsoleMode(input_handle, &mut original_mode) };
3383        if got_mode == 0 {
3384            return Err(PyRuntimeError::new_err(
3385                "NativeTerminalInput requires an attached Windows console stdin",
3386            ));
3387        }
3388
3389        let active_mode = native_terminal_input_mode(original_mode);
3390        let set_mode = unsafe { SetConsoleMode(input_handle, active_mode) };
3391        if set_mode == 0 {
3392            return Err(to_py_err(std::io::Error::last_os_error()));
3393        }
3394        append_native_terminal_input_trace_line(&format!(
3395            "[{:.6}] native_terminal_input start handle={} original_mode={:#010x} active_mode={:#010x}",
3396            unix_now_seconds(),
3397            input_handle as usize,
3398            original_mode,
3399            active_mode,
3400        ));
3401
3402        self.stop.store(false, Ordering::Release);
3403        self.capturing.store(true, Ordering::Release);
3404        {
3405            let mut state = self.state.lock().expect("terminal input mutex poisoned");
3406            state.events.clear();
3407            state.closed = false;
3408        }
3409        *self
3410            .console
3411            .lock()
3412            .expect("terminal input console mutex poisoned") = Some(ActiveTerminalInputCapture {
3413            input_handle: input_handle as usize,
3414            original_mode,
3415            active_mode,
3416        });
3417
3418        let state = Arc::clone(&self.state);
3419        let condvar = Arc::clone(&self.condvar);
3420        let stop = Arc::clone(&self.stop);
3421        let capturing = Arc::clone(&self.capturing);
3422        let input_handle_raw = input_handle as usize;
3423        *worker_guard = Some(thread::spawn(move || {
3424            native_terminal_input_worker(input_handle_raw, state, condvar, stop, capturing);
3425        }));
3426        Ok(())
3427    }
3428}
3429
3430#[pymethods]
3431impl NativeTerminalInputEvent {
3432    #[getter]
3433    fn data(&self, py: Python<'_>) -> Py<PyAny> {
3434        PyBytes::new(py, &self.data).into_any().unbind()
3435    }
3436
3437    #[getter]
3438    fn submit(&self) -> bool {
3439        self.submit
3440    }
3441
3442    #[getter]
3443    fn shift(&self) -> bool {
3444        self.shift
3445    }
3446
3447    #[getter]
3448    fn ctrl(&self) -> bool {
3449        self.ctrl
3450    }
3451
3452    #[getter]
3453    fn alt(&self) -> bool {
3454        self.alt
3455    }
3456
3457    #[getter]
3458    fn virtual_key_code(&self) -> u16 {
3459        self.virtual_key_code
3460    }
3461
3462    #[getter]
3463    fn repeat_count(&self) -> u16 {
3464        self.repeat_count
3465    }
3466
3467    fn __repr__(&self) -> String {
3468        format!(
3469            "NativeTerminalInputEvent(data={:?}, submit={}, shift={}, ctrl={}, alt={}, virtual_key_code={}, repeat_count={})",
3470            self.data,
3471            self.submit,
3472            self.shift,
3473            self.ctrl,
3474            self.alt,
3475            self.virtual_key_code,
3476            self.repeat_count,
3477        )
3478    }
3479}
3480
3481#[pymethods]
3482impl NativeTerminalInput {
3483    #[new]
3484    fn new() -> Self {
3485        Self {
3486            state: Arc::new(Mutex::new(TerminalInputState {
3487                events: VecDeque::new(),
3488                closed: true,
3489            })),
3490            condvar: Arc::new(Condvar::new()),
3491            stop: Arc::new(AtomicBool::new(false)),
3492            capturing: Arc::new(AtomicBool::new(false)),
3493            worker: Mutex::new(None),
3494            #[cfg(windows)]
3495            console: Mutex::new(None),
3496        }
3497    }
3498
3499    fn start(&self) -> PyResult<()> {
3500        #[cfg(windows)]
3501        {
3502            self.start_impl()
3503        }
3504
3505        #[cfg(not(windows))]
3506        {
3507            Err(PyRuntimeError::new_err(
3508                "NativeTerminalInput is only available on Windows consoles",
3509            ))
3510        }
3511    }
3512
3513    fn stop(&self, py: Python<'_>) -> PyResult<()> {
3514        py.allow_threads(|| self.stop_impl())
3515    }
3516
3517    fn close(&self, py: Python<'_>) -> PyResult<()> {
3518        py.allow_threads(|| self.stop_impl())
3519    }
3520
3521    fn available(&self) -> bool {
3522        !self
3523            .state
3524            .lock()
3525            .expect("terminal input mutex poisoned")
3526            .events
3527            .is_empty()
3528    }
3529
3530    #[getter]
3531    fn capturing(&self) -> bool {
3532        self.capturing.load(Ordering::Acquire)
3533    }
3534
3535    #[getter]
3536    fn original_console_mode(&self) -> Option<u32> {
3537        #[cfg(windows)]
3538        {
3539            return self
3540                .console
3541                .lock()
3542                .expect("terminal input console mutex poisoned")
3543                .as_ref()
3544                .map(|capture| capture.original_mode);
3545        }
3546
3547        #[cfg(not(windows))]
3548        {
3549            None
3550        }
3551    }
3552
3553    #[getter]
3554    fn active_console_mode(&self) -> Option<u32> {
3555        #[cfg(windows)]
3556        {
3557            return self
3558                .console
3559                .lock()
3560                .expect("terminal input console mutex poisoned")
3561                .as_ref()
3562                .map(|capture| capture.active_mode);
3563        }
3564
3565        #[cfg(not(windows))]
3566        {
3567            None
3568        }
3569    }
3570
3571    #[pyo3(signature = (timeout=None))]
3572    fn read_event(
3573        &self,
3574        py: Python<'_>,
3575        timeout: Option<f64>,
3576    ) -> PyResult<Py<NativeTerminalInputEvent>> {
3577        let event = self.wait_for_event(py, timeout)?;
3578        Self::event_to_py(py, event)
3579    }
3580
3581    fn read_event_non_blocking(
3582        &self,
3583        py: Python<'_>,
3584    ) -> PyResult<Option<Py<NativeTerminalInputEvent>>> {
3585        if let Some(event) = self.next_event() {
3586            return Self::event_to_py(py, event).map(Some);
3587        }
3588        if self
3589            .state
3590            .lock()
3591            .expect("terminal input mutex poisoned")
3592            .closed
3593        {
3594            return Err(PyRuntimeError::new_err("Native terminal input is closed"));
3595        }
3596        Ok(None)
3597    }
3598
3599    #[pyo3(signature = (timeout=None))]
3600    fn read(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<Py<PyAny>> {
3601        let event = self.wait_for_event(py, timeout)?;
3602        Ok(PyBytes::new(py, &event.data).into_any().unbind())
3603    }
3604
3605    fn read_non_blocking(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
3606        if let Some(event) = self.next_event() {
3607            return Ok(Some(PyBytes::new(py, &event.data).into_any().unbind()));
3608        }
3609        if self
3610            .state
3611            .lock()
3612            .expect("terminal input mutex poisoned")
3613            .closed
3614        {
3615            return Err(PyRuntimeError::new_err("Native terminal input is closed"));
3616        }
3617        Ok(None)
3618    }
3619
3620    fn drain(&self, py: Python<'_>) -> Vec<Py<PyAny>> {
3621        let mut guard = self.state.lock().expect("terminal input mutex poisoned");
3622        guard
3623            .events
3624            .drain(..)
3625            .map(|event| PyBytes::new(py, &event.data).into_any().unbind())
3626            .collect()
3627    }
3628
3629    fn drain_events(&self, py: Python<'_>) -> PyResult<Vec<Py<NativeTerminalInputEvent>>> {
3630        let mut guard = self.state.lock().expect("terminal input mutex poisoned");
3631        guard
3632            .events
3633            .drain(..)
3634            .map(|event| Self::event_to_py(py, event))
3635            .collect()
3636    }
3637}
3638
3639impl Drop for NativeTerminalInput {
3640    fn drop(&mut self) {
3641        let _ = self.stop_impl();
3642    }
3643}
3644
3645impl NativeRunningProcess {
3646    fn start_impl(&self) -> PyResult<()> {
3647        running_process_core::rp_rust_debug_scope!(
3648            "running_process_py::NativeRunningProcess::start"
3649        );
3650        self.inner.start().map_err(to_py_err)
3651    }
3652
3653    fn wait_impl(&self, py: Python<'_>, timeout: Option<f64>) -> PyResult<i32> {
3654        running_process_core::rp_rust_debug_scope!(
3655            "running_process_py::NativeRunningProcess::wait"
3656        );
3657        py.allow_threads(|| {
3658            self.inner
3659                .wait(timeout.map(Duration::from_secs_f64))
3660                .map_err(process_err_to_py)
3661        })
3662    }
3663
3664    fn kill_impl(&self) -> PyResult<()> {
3665        running_process_core::rp_rust_debug_scope!(
3666            "running_process_py::NativeRunningProcess::kill"
3667        );
3668        self.inner.kill().map_err(to_py_err)
3669    }
3670
3671    fn terminate_impl(&self) -> PyResult<()> {
3672        running_process_core::rp_rust_debug_scope!(
3673            "running_process_py::NativeRunningProcess::terminate"
3674        );
3675        self.inner.terminate().map_err(to_py_err)
3676    }
3677
3678    fn close_impl(&self, py: Python<'_>) -> PyResult<()> {
3679        running_process_core::rp_rust_debug_scope!(
3680            "running_process_py::NativeRunningProcess::close"
3681        );
3682        py.allow_threads(|| self.inner.close().map_err(process_err_to_py))
3683    }
3684
3685    fn send_interrupt_impl(&self) -> PyResult<()> {
3686        running_process_core::rp_rust_debug_scope!(
3687            "running_process_py::NativeRunningProcess::send_interrupt"
3688        );
3689        let pid = self
3690            .inner
3691            .pid()
3692            .ok_or_else(|| PyRuntimeError::new_err("process is not running"))?;
3693
3694        #[cfg(windows)]
3695        {
3696            public_symbols::rp_windows_generate_console_ctrl_break_public(pid, self.creationflags)
3697        }
3698
3699        #[cfg(unix)]
3700        {
3701            if self.create_process_group {
3702                unix_signal_process_group(pid as i32, UnixSignal::Interrupt).map_err(to_py_err)?;
3703            } else {
3704                unix_signal_process(pid, UnixSignal::Interrupt).map_err(to_py_err)?;
3705            }
3706            Ok(())
3707        }
3708    }
3709
3710    fn decode_line_to_string(&self, py: Python<'_>, line: &[u8]) -> PyResult<String> {
3711        if !self.text {
3712            return Ok(String::from_utf8_lossy(line).into_owned());
3713        }
3714        PyBytes::new(py, line)
3715            .call_method1(
3716                "decode",
3717                (
3718                    self.encoding.as_deref().unwrap_or("utf-8"),
3719                    self.errors.as_deref().unwrap_or("replace"),
3720                ),
3721            )?
3722            .extract()
3723    }
3724
3725    fn captured_stream_text(&self, py: Python<'_>, stream: StreamKind) -> PyResult<String> {
3726        let lines = match stream {
3727            StreamKind::Stdout => self.inner.captured_stdout(),
3728            StreamKind::Stderr => self.inner.captured_stderr(),
3729        };
3730        let mut text = String::new();
3731        for (index, line) in lines.iter().enumerate() {
3732            if index > 0 {
3733                text.push('\n');
3734            }
3735            text.push_str(&self.decode_line_to_string(py, line)?);
3736        }
3737        Ok(text)
3738    }
3739
3740    fn captured_combined_text(&self, py: Python<'_>) -> PyResult<String> {
3741        let lines = self.inner.captured_combined();
3742        let mut text = String::new();
3743        for (index, event) in lines.iter().enumerate() {
3744            if index > 0 {
3745                text.push('\n');
3746            }
3747            text.push_str(&self.decode_line_to_string(py, &event.line)?);
3748        }
3749        Ok(text)
3750    }
3751
3752    fn read_status_text(
3753        &self,
3754        stream: Option<StreamKind>,
3755        timeout: Option<Duration>,
3756    ) -> PyResult<ReadStatus<Vec<u8>>> {
3757        Ok(match stream {
3758            Some(kind) => self.inner.read_stream(kind, timeout),
3759            None => match self.inner.read_combined(timeout) {
3760                ReadStatus::Line(StreamEvent { line, .. }) => ReadStatus::Line(line),
3761                ReadStatus::Timeout => ReadStatus::Timeout,
3762                ReadStatus::Eof => ReadStatus::Eof,
3763            },
3764        })
3765    }
3766
3767    fn find_expect_match(
3768        &self,
3769        buffer: &str,
3770        pattern: &str,
3771        is_regex: bool,
3772    ) -> PyResult<Option<ExpectDetails>> {
3773        if !is_regex {
3774            let Some(start) = buffer.find(pattern) else {
3775                return Ok(None);
3776            };
3777            return Ok(Some((
3778                pattern.to_string(),
3779                start,
3780                start + pattern.len(),
3781                Vec::new(),
3782            )));
3783        }
3784
3785        let regex = Regex::new(pattern).map_err(to_py_err)?;
3786        let Some(captures) = regex.captures(buffer) else {
3787            return Ok(None);
3788        };
3789        let whole = captures
3790            .get(0)
3791            .ok_or_else(|| PyRuntimeError::new_err("regex capture missing group 0"))?;
3792        let groups = captures
3793            .iter()
3794            .skip(1)
3795            .map(|group| {
3796                group
3797                    .map(|value| value.as_str().to_string())
3798                    .unwrap_or_default()
3799            })
3800            .collect();
3801        Ok(Some((
3802            whole.as_str().to_string(),
3803            whole.start(),
3804            whole.end(),
3805            groups,
3806        )))
3807    }
3808
3809    fn decode_line(&self, py: Python<'_>, line: &[u8]) -> PyResult<Py<PyAny>> {
3810        if !self.text {
3811            return Ok(PyBytes::new(py, line).into_any().unbind());
3812        }
3813        Ok(PyBytes::new(py, line)
3814            .call_method1(
3815                "decode",
3816                (
3817                    self.encoding.as_deref().unwrap_or("utf-8"),
3818                    self.errors.as_deref().unwrap_or("replace"),
3819                ),
3820            )?
3821            .into_any()
3822            .unbind())
3823    }
3824}
3825
3826impl NativePtyBuffer {
3827    fn decode_chunk(&self, py: Python<'_>, line: &[u8]) -> PyResult<Py<PyAny>> {
3828        if !self.text {
3829            return Ok(PyBytes::new(py, line).into_any().unbind());
3830        }
3831        Ok(PyBytes::new(py, line)
3832            .call_method1("decode", (&self.encoding, &self.errors))?
3833            .into_any()
3834            .unbind())
3835    }
3836}
3837
3838#[pymethods]
3839impl NativeIdleDetector {
3840    #[new]
3841    #[allow(clippy::too_many_arguments)]
3842    #[pyo3(signature = (timeout_seconds, stability_window_seconds, sample_interval_seconds, enabled_signal, reset_on_input=true, reset_on_output=true, count_control_churn_as_output=true, initial_idle_for_seconds=0.0))]
3843    fn new(
3844        py: Python<'_>,
3845        timeout_seconds: f64,
3846        stability_window_seconds: f64,
3847        sample_interval_seconds: f64,
3848        enabled_signal: Py<NativeSignalBool>,
3849        reset_on_input: bool,
3850        reset_on_output: bool,
3851        count_control_churn_as_output: bool,
3852        initial_idle_for_seconds: f64,
3853    ) -> Self {
3854        let now = Instant::now();
3855        let initial_idle_for_seconds = initial_idle_for_seconds.max(0.0);
3856        let last_reset_at = now
3857            .checked_sub(Duration::from_secs_f64(initial_idle_for_seconds))
3858            .unwrap_or(now);
3859        let enabled = enabled_signal.borrow(py).value.clone();
3860        Self {
3861            core: Arc::new(IdleDetectorCore {
3862                timeout_seconds,
3863                stability_window_seconds,
3864                sample_interval_seconds,
3865                reset_on_input,
3866                reset_on_output,
3867                count_control_churn_as_output,
3868                enabled,
3869                state: Mutex::new(IdleMonitorState {
3870                    last_reset_at,
3871                    returncode: None,
3872                    interrupted: false,
3873                }),
3874                condvar: Condvar::new(),
3875            }),
3876        }
3877    }
3878
3879    #[getter]
3880    fn enabled(&self) -> bool {
3881        self.core.enabled()
3882    }
3883
3884    #[setter]
3885    fn set_enabled(&self, enabled: bool) {
3886        self.core.set_enabled(enabled);
3887    }
3888
3889    fn record_input(&self, byte_count: usize) {
3890        self.core.record_input(byte_count);
3891    }
3892
3893    fn record_output(&self, data: &[u8]) {
3894        self.core.record_output(data);
3895    }
3896
3897    fn mark_exit(&self, returncode: i32, interrupted: bool) {
3898        self.core.mark_exit(returncode, interrupted);
3899    }
3900
3901    #[pyo3(signature = (timeout=None))]
3902    fn wait(&self, py: Python<'_>, timeout: Option<f64>) -> (bool, String, f64, Option<i32>) {
3903        py.allow_threads(|| self.core.wait(timeout))
3904    }
3905}
3906
3907fn control_churn_bytes(data: &[u8]) -> usize {
3908    let mut total = 0;
3909    let mut index = 0;
3910    while index < data.len() {
3911        let byte = data[index];
3912        if byte == 0x1B {
3913            let start = index;
3914            index += 1;
3915            if index < data.len() && data[index] == b'[' {
3916                index += 1;
3917                while index < data.len() {
3918                    let current = data[index];
3919                    index += 1;
3920                    if (0x40..=0x7E).contains(&current) {
3921                        break;
3922                    }
3923                }
3924            }
3925            total += index - start;
3926            continue;
3927        }
3928        if matches!(byte, 0x08 | 0x0D | 0x7F) {
3929            total += 1;
3930        }
3931        index += 1;
3932    }
3933    total
3934}
3935
3936fn command_builder_from_argv(argv: &[String]) -> CommandBuilder {
3937    let mut command = CommandBuilder::new(&argv[0]);
3938    if argv.len() > 1 {
3939        command.args(
3940            argv[1..]
3941                .iter()
3942                .map(OsString::from)
3943                .collect::<Vec<OsString>>(),
3944        );
3945    }
3946    command
3947}
3948
3949#[inline(never)]
3950fn spawn_pty_reader(
3951    mut reader: Box<dyn Read + Send>,
3952    shared: Arc<PtyReadShared>,
3953    echo: Arc<AtomicBool>,
3954    idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
3955    output_bytes_total: Arc<AtomicUsize>,
3956    control_churn_bytes_total: Arc<AtomicUsize>,
3957) {
3958    running_process_core::rp_rust_debug_scope!("running_process_py::spawn_pty_reader");
3959    let mut chunk = [0_u8; 4096];
3960    loop {
3961        match reader.read(&mut chunk) {
3962            Ok(0) => break,
3963            Ok(n) => {
3964                let data = &chunk[..n];
3965
3966                // Output accounting (no GIL needed).
3967                let churn = control_churn_bytes(data);
3968                let visible = data.len().saturating_sub(churn);
3969                output_bytes_total.fetch_add(visible, Ordering::Relaxed);
3970                control_churn_bytes_total.fetch_add(churn, Ordering::Relaxed);
3971
3972                // Echo to stdout if enabled (no GIL needed).
3973                if echo.load(Ordering::Relaxed) {
3974                    let _ = std::io::stdout().write_all(data);
3975                    let _ = std::io::stdout().flush();
3976                }
3977
3978                // Feed idle detector directly (no GIL needed).
3979                if let Some(detector) = idle_detector
3980                    .lock()
3981                    .expect("idle detector mutex poisoned")
3982                    .as_ref()
3983                {
3984                    detector.record_output(data);
3985                }
3986
3987                let mut guard = shared.state.lock().expect("pty read mutex poisoned");
3988                guard.chunks.push_back(data.to_vec());
3989                shared.condvar.notify_all();
3990            }
3991            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
3992            Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
3993                thread::sleep(Duration::from_millis(10));
3994                continue;
3995            }
3996            Err(_) => break,
3997        }
3998    }
3999    let mut guard = shared.state.lock().expect("pty read mutex poisoned");
4000    guard.closed = true;
4001    shared.condvar.notify_all();
4002}
4003
4004fn portable_exit_code(status: portable_pty::ExitStatus) -> i32 {
4005    if let Some(signal) = status.signal() {
4006        let signal = signal.to_ascii_lowercase();
4007        if signal.contains("interrupt") {
4008            return -2;
4009        }
4010        if signal.contains("terminated") {
4011            return -15;
4012        }
4013        if signal.contains("killed") {
4014            return -9;
4015        }
4016    }
4017    status.exit_code() as i32
4018}
4019
4020#[cfg(windows)]
4021#[inline(never)]
4022fn assign_child_to_windows_kill_on_close_job(
4023    handle: Option<std::os::windows::io::RawHandle>,
4024) -> PyResult<WindowsJobHandle> {
4025    running_process_core::rp_rust_debug_scope!(
4026        "running_process_py::assign_child_to_windows_kill_on_close_job"
4027    );
4028    use std::mem::zeroed;
4029
4030    use winapi::shared::minwindef::FALSE;
4031    use winapi::um::handleapi::INVALID_HANDLE_VALUE;
4032    use winapi::um::jobapi2::{
4033        AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject,
4034    };
4035    use winapi::um::winnt::{
4036        JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
4037        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
4038    };
4039
4040    let Some(handle) = handle else {
4041        return Err(PyRuntimeError::new_err(
4042            "Pseudo-terminal child does not expose a Windows process handle",
4043        ));
4044    };
4045
4046    let job = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
4047    if job.is_null() || job == INVALID_HANDLE_VALUE {
4048        return Err(to_py_err(std::io::Error::last_os_error()));
4049    }
4050
4051    let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
4052    info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
4053    let result = unsafe {
4054        SetInformationJobObject(
4055            job,
4056            JobObjectExtendedLimitInformation,
4057            (&mut info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
4058            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
4059        )
4060    };
4061    if result == FALSE {
4062        let err = std::io::Error::last_os_error();
4063        unsafe {
4064            winapi::um::handleapi::CloseHandle(job);
4065        }
4066        return Err(to_py_err(err));
4067    }
4068
4069    let result = unsafe { AssignProcessToJobObject(job, handle.cast()) };
4070    if result == FALSE {
4071        let err = std::io::Error::last_os_error();
4072        unsafe {
4073            winapi::um::handleapi::CloseHandle(job);
4074        }
4075        return Err(to_py_err(err));
4076    }
4077
4078    Ok(WindowsJobHandle(job as usize))
4079}
4080
4081#[cfg(windows)]
4082#[inline(never)]
4083fn apply_windows_pty_priority(
4084    handle: Option<std::os::windows::io::RawHandle>,
4085    nice: Option<i32>,
4086) -> PyResult<()> {
4087    running_process_core::rp_rust_debug_scope!("running_process_py::apply_windows_pty_priority");
4088    use winapi::um::processthreadsapi::SetPriorityClass;
4089    use winapi::um::winbase::{
4090        ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, HIGH_PRIORITY_CLASS,
4091        IDLE_PRIORITY_CLASS,
4092    };
4093
4094    let Some(handle) = handle else {
4095        return Ok(());
4096    };
4097    let flags = match nice {
4098        Some(value) if value >= 15 => IDLE_PRIORITY_CLASS,
4099        Some(value) if value >= 1 => BELOW_NORMAL_PRIORITY_CLASS,
4100        Some(value) if value <= -15 => HIGH_PRIORITY_CLASS,
4101        Some(value) if value <= -1 => ABOVE_NORMAL_PRIORITY_CLASS,
4102        _ => 0,
4103    };
4104    if flags == 0 {
4105        return Ok(());
4106    }
4107    let result = unsafe { SetPriorityClass(handle.cast(), flags) };
4108    if result == 0 {
4109        return Err(to_py_err(std::io::Error::last_os_error()));
4110    }
4111    Ok(())
4112}
4113
4114#[cfg(test)]
4115mod tests {
4116    use super::*;
4117
4118    #[cfg(windows)]
4119    use winapi::um::wincon::{
4120        ENABLE_ECHO_INPUT, ENABLE_EXTENDED_FLAGS, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
4121        ENABLE_QUICK_EDIT_MODE, ENABLE_WINDOW_INPUT,
4122    };
4123    #[cfg(windows)]
4124    use winapi::um::wincontypes::{
4125        KEY_EVENT_RECORD, LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, SHIFT_PRESSED,
4126    };
4127    #[cfg(windows)]
4128    use winapi::um::winuser::{VK_RETURN, VK_TAB, VK_UP};
4129
4130    #[cfg(windows)]
4131    fn key_event(
4132        virtual_key_code: u16,
4133        unicode: u16,
4134        control_key_state: u32,
4135        repeat_count: u16,
4136    ) -> KEY_EVENT_RECORD {
4137        let mut event: KEY_EVENT_RECORD = unsafe { std::mem::zeroed() };
4138        event.bKeyDown = 1;
4139        event.wRepeatCount = repeat_count;
4140        event.wVirtualKeyCode = virtual_key_code;
4141        event.wVirtualScanCode = 0;
4142        event.dwControlKeyState = control_key_state;
4143        unsafe {
4144            *event.uChar.UnicodeChar_mut() = unicode;
4145        }
4146        event
4147    }
4148
4149    #[test]
4150    #[cfg(windows)]
4151    fn native_terminal_input_mode_disables_cooked_console_flags() {
4152        let original_mode =
4153            ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE;
4154
4155        let active_mode = native_terminal_input_mode(original_mode);
4156
4157        assert_eq!(active_mode & ENABLE_ECHO_INPUT, 0);
4158        assert_eq!(active_mode & ENABLE_LINE_INPUT, 0);
4159        assert_eq!(active_mode & ENABLE_PROCESSED_INPUT, 0);
4160        assert_eq!(active_mode & ENABLE_QUICK_EDIT_MODE, 0);
4161        assert_ne!(active_mode & ENABLE_EXTENDED_FLAGS, 0);
4162        assert_ne!(active_mode & ENABLE_WINDOW_INPUT, 0);
4163    }
4164
4165    #[test]
4166    #[cfg(windows)]
4167    fn translate_terminal_input_preserves_submit_hint_for_enter() {
4168        let event = translate_console_key_event(&key_event(VK_RETURN as u16, '\r' as u16, 0, 1))
4169            .expect("enter should translate");
4170        assert_eq!(event.data, b"\r");
4171        assert!(event.submit);
4172    }
4173
4174    #[test]
4175    #[cfg(windows)]
4176    fn translate_terminal_input_keeps_shift_enter_non_submit() {
4177        let event = translate_console_key_event(&key_event(
4178            VK_RETURN as u16,
4179            '\r' as u16,
4180            SHIFT_PRESSED,
4181            1,
4182        ))
4183        .expect("shift-enter should translate");
4184        // Shift+Enter emits CSI u sequence so downstream apps can
4185        // distinguish it from plain Enter.
4186        assert_eq!(event.data, b"\x1b[13;2u");
4187        assert!(!event.submit);
4188        assert!(event.shift);
4189    }
4190
4191    #[test]
4192    #[cfg(windows)]
4193    fn translate_terminal_input_encodes_shift_tab() {
4194        let event = translate_console_key_event(&key_event(VK_TAB as u16, 0, SHIFT_PRESSED, 1))
4195            .expect("shift-tab should translate");
4196        assert_eq!(event.data, b"\x1b[Z");
4197        assert!(!event.submit);
4198    }
4199
4200    #[test]
4201    #[cfg(windows)]
4202    fn translate_terminal_input_encodes_modified_arrows() {
4203        let event = translate_console_key_event(&key_event(
4204            VK_UP as u16,
4205            0,
4206            SHIFT_PRESSED | LEFT_CTRL_PRESSED,
4207            1,
4208        ))
4209        .expect("modified arrow should translate");
4210        assert_eq!(event.data, b"\x1b[1;6A");
4211    }
4212
4213    #[test]
4214    #[cfg(windows)]
4215    fn translate_terminal_input_encodes_alt_printable_with_escape_prefix() {
4216        let event =
4217            translate_console_key_event(&key_event(b'X' as u16, 'x' as u16, LEFT_ALT_PRESSED, 1))
4218                .expect("alt printable should translate");
4219        assert_eq!(event.data, b"\x1bx");
4220    }
4221
4222    #[test]
4223    #[cfg(windows)]
4224    fn translate_terminal_input_encodes_ctrl_printable_as_control_character() {
4225        let event =
4226            translate_console_key_event(&key_event(b'C' as u16, 'c' as u16, LEFT_CTRL_PRESSED, 1))
4227                .expect("ctrl-c should translate");
4228        assert_eq!(event.data, [0x03]);
4229    }
4230
4231    #[test]
4232    #[cfg(windows)]
4233    fn translate_terminal_input_ignores_keyup_events() {
4234        let mut event = key_event(VK_RETURN as u16, '\r' as u16, 0, 1);
4235        event.bKeyDown = 0;
4236        assert!(translate_console_key_event(&event).is_none());
4237    }
4238
4239    // ── control_churn_bytes tests ──
4240
4241    #[test]
4242    fn control_churn_bytes_empty() {
4243        assert_eq!(control_churn_bytes(b""), 0);
4244    }
4245
4246    #[test]
4247    fn control_churn_bytes_plain_text() {
4248        assert_eq!(control_churn_bytes(b"hello world"), 0);
4249    }
4250
4251    #[test]
4252    fn control_churn_bytes_ansi_csi_sequence() {
4253        // \x1b[31m = 5 bytes of control churn, \x1b[0m = 4 bytes
4254        assert_eq!(control_churn_bytes(b"\x1b[31mhello\x1b[0m"), 9);
4255    }
4256
4257    #[test]
4258    fn control_churn_bytes_backspace_cr_del() {
4259        assert_eq!(control_churn_bytes(b"\x08\x0D\x7F"), 3);
4260    }
4261
4262    #[test]
4263    fn control_churn_bytes_bare_escape() {
4264        // Bare ESC with no CSI sequence following
4265        assert_eq!(control_churn_bytes(b"\x1b"), 1);
4266    }
4267
4268    #[test]
4269    fn control_churn_bytes_mixed() {
4270        // \x1b[J = 3 bytes CSI + 1 byte BS = 4
4271        assert_eq!(control_churn_bytes(b"ok\x1b[Jmore\x08"), 4);
4272    }
4273
4274    // ── input_contains_newline tests ──
4275
4276    #[test]
4277    fn input_contains_newline_cr() {
4278        assert!(input_contains_newline(b"hello\rworld"));
4279    }
4280
4281    #[test]
4282    fn input_contains_newline_lf() {
4283        assert!(input_contains_newline(b"hello\nworld"));
4284    }
4285
4286    #[test]
4287    fn input_contains_newline_none() {
4288        assert!(!input_contains_newline(b"hello world"));
4289    }
4290
4291    #[test]
4292    fn input_contains_newline_empty() {
4293        assert!(!input_contains_newline(b""));
4294    }
4295
4296    // ── is_ignorable_process_control_error tests ──
4297
4298    #[test]
4299    fn ignorable_error_not_found() {
4300        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
4301        assert!(is_ignorable_process_control_error(&err));
4302    }
4303
4304    #[test]
4305    fn ignorable_error_invalid_input() {
4306        let err = std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad input");
4307        assert!(is_ignorable_process_control_error(&err));
4308    }
4309
4310    #[test]
4311    fn ignorable_error_permission_denied_is_not_ignorable() {
4312        let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
4313        assert!(!is_ignorable_process_control_error(&err));
4314    }
4315
4316    #[test]
4317    #[cfg(unix)]
4318    fn ignorable_error_esrch() {
4319        let err = std::io::Error::from_raw_os_error(libc::ESRCH);
4320        assert!(is_ignorable_process_control_error(&err));
4321    }
4322
4323    // ── Windows-only pure function tests ──
4324
4325    #[test]
4326    #[cfg(windows)]
4327    fn windows_terminal_input_payload_passthrough() {
4328        let result = windows_terminal_input_payload(b"hello");
4329        assert_eq!(result, b"hello");
4330    }
4331
4332    #[test]
4333    #[cfg(windows)]
4334    fn windows_terminal_input_payload_lone_lf_becomes_cr() {
4335        let result = windows_terminal_input_payload(b"\n");
4336        assert_eq!(result, b"\r");
4337    }
4338
4339    #[test]
4340    #[cfg(windows)]
4341    fn windows_terminal_input_payload_crlf_preserved() {
4342        let result = windows_terminal_input_payload(b"\r\n");
4343        assert_eq!(result, b"\r\n");
4344    }
4345
4346    #[test]
4347    #[cfg(windows)]
4348    fn windows_terminal_input_payload_lone_cr_preserved() {
4349        let result = windows_terminal_input_payload(b"\r");
4350        assert_eq!(result, b"\r");
4351    }
4352
4353    #[test]
4354    #[cfg(windows)]
4355    fn terminal_input_modifier_none() {
4356        assert!(terminal_input_modifier_parameter(false, false, false).is_none());
4357    }
4358
4359    #[test]
4360    #[cfg(windows)]
4361    fn terminal_input_modifier_shift() {
4362        assert_eq!(
4363            terminal_input_modifier_parameter(true, false, false),
4364            Some(2)
4365        );
4366    }
4367
4368    #[test]
4369    #[cfg(windows)]
4370    fn terminal_input_modifier_alt() {
4371        assert_eq!(
4372            terminal_input_modifier_parameter(false, true, false),
4373            Some(3)
4374        );
4375    }
4376
4377    #[test]
4378    #[cfg(windows)]
4379    fn terminal_input_modifier_ctrl() {
4380        assert_eq!(
4381            terminal_input_modifier_parameter(false, false, true),
4382            Some(5)
4383        );
4384    }
4385
4386    #[test]
4387    #[cfg(windows)]
4388    fn terminal_input_modifier_shift_ctrl() {
4389        assert_eq!(
4390            terminal_input_modifier_parameter(true, false, true),
4391            Some(6)
4392        );
4393    }
4394
4395    #[test]
4396    #[cfg(windows)]
4397    fn control_character_for_unicode_letters() {
4398        assert_eq!(control_character_for_unicode('A' as u16), Some(0x01));
4399        assert_eq!(control_character_for_unicode('C' as u16), Some(0x03));
4400        assert_eq!(control_character_for_unicode('Z' as u16), Some(0x1A));
4401    }
4402
4403    #[test]
4404    #[cfg(windows)]
4405    fn control_character_for_unicode_special() {
4406        assert_eq!(control_character_for_unicode('@' as u16), Some(0x00));
4407        assert_eq!(control_character_for_unicode('[' as u16), Some(0x1B));
4408    }
4409
4410    #[test]
4411    #[cfg(windows)]
4412    fn control_character_for_unicode_digit_returns_none() {
4413        assert!(control_character_for_unicode('1' as u16).is_none());
4414    }
4415
4416    #[test]
4417    #[cfg(windows)]
4418    fn format_terminal_input_bytes_empty() {
4419        assert_eq!(format_terminal_input_bytes(b""), "[]");
4420    }
4421
4422    #[test]
4423    #[cfg(windows)]
4424    fn format_terminal_input_bytes_multi() {
4425        assert_eq!(format_terminal_input_bytes(&[0x41, 0x42]), "[41 42]");
4426    }
4427
4428    #[test]
4429    #[cfg(windows)]
4430    fn repeated_tilde_sequence_no_modifier() {
4431        assert_eq!(repeated_tilde_sequence(3, None, 1), b"\x1b[3~");
4432    }
4433
4434    #[test]
4435    #[cfg(windows)]
4436    fn repeated_tilde_sequence_with_modifier() {
4437        assert_eq!(repeated_tilde_sequence(3, Some(2), 1), b"\x1b[3;2~");
4438    }
4439
4440    #[test]
4441    #[cfg(windows)]
4442    fn repeated_tilde_sequence_repeated() {
4443        let result = repeated_tilde_sequence(3, None, 3);
4444        assert_eq!(result, b"\x1b[3~\x1b[3~\x1b[3~");
4445    }
4446
4447    #[test]
4448    #[cfg(windows)]
4449    fn repeated_modified_sequence_no_modifier() {
4450        let result = repeated_modified_sequence(b"\x1b[A", None, 1);
4451        assert_eq!(result, b"\x1b[A");
4452    }
4453
4454    #[test]
4455    #[cfg(windows)]
4456    fn repeated_modified_sequence_with_modifier() {
4457        // Shift modifier (2) applied to Up arrow
4458        let result = repeated_modified_sequence(b"\x1b[A", Some(2), 1);
4459        assert_eq!(result, b"\x1b[1;2A");
4460    }
4461
4462    #[test]
4463    #[cfg(windows)]
4464    fn repeated_modified_sequence_repeated() {
4465        let result = repeated_modified_sequence(b"\x1b[A", None, 2);
4466        assert_eq!(result, b"\x1b[A\x1b[A");
4467    }
4468
4469    #[test]
4470    #[cfg(windows)]
4471    fn repeat_terminal_input_bytes_single() {
4472        let result = repeat_terminal_input_bytes(b"\r", 1);
4473        assert_eq!(result, b"\r");
4474    }
4475
4476    #[test]
4477    #[cfg(windows)]
4478    fn repeat_terminal_input_bytes_multiple() {
4479        let result = repeat_terminal_input_bytes(b"ab", 3);
4480        assert_eq!(result, b"ababab");
4481    }
4482
4483    #[test]
4484    #[cfg(windows)]
4485    fn repeat_terminal_input_bytes_zero_clamps_to_one() {
4486        let result = repeat_terminal_input_bytes(b"x", 0);
4487        assert_eq!(result, b"x");
4488    }
4489
4490    // ── B1: Windows Console Key Translation (navigation keys) ──
4491
4492    #[test]
4493    #[cfg(windows)]
4494    fn translate_console_key_home() {
4495        use winapi::um::winuser::VK_HOME;
4496        let event = translate_console_key_event(&key_event(VK_HOME as u16, 0, 0, 1))
4497            .expect("VK_HOME should translate");
4498        assert_eq!(event.data, b"\x1b[H");
4499        assert!(!event.submit);
4500    }
4501
4502    #[test]
4503    #[cfg(windows)]
4504    fn translate_console_key_end() {
4505        use winapi::um::winuser::VK_END;
4506        let event = translate_console_key_event(&key_event(VK_END as u16, 0, 0, 1))
4507            .expect("VK_END should translate");
4508        assert_eq!(event.data, b"\x1b[F");
4509        assert!(!event.submit);
4510    }
4511
4512    #[test]
4513    #[cfg(windows)]
4514    fn translate_console_key_insert() {
4515        use winapi::um::winuser::VK_INSERT;
4516        let event = translate_console_key_event(&key_event(VK_INSERT as u16, 0, 0, 1))
4517            .expect("VK_INSERT should translate");
4518        assert_eq!(event.data, b"\x1b[2~");
4519        assert!(!event.submit);
4520    }
4521
4522    #[test]
4523    #[cfg(windows)]
4524    fn translate_console_key_delete() {
4525        use winapi::um::winuser::VK_DELETE;
4526        let event = translate_console_key_event(&key_event(VK_DELETE as u16, 0, 0, 1))
4527            .expect("VK_DELETE should translate");
4528        assert_eq!(event.data, b"\x1b[3~");
4529        assert!(!event.submit);
4530    }
4531
4532    #[test]
4533    #[cfg(windows)]
4534    fn translate_console_key_page_up() {
4535        use winapi::um::winuser::VK_PRIOR;
4536        let event = translate_console_key_event(&key_event(VK_PRIOR as u16, 0, 0, 1))
4537            .expect("VK_PRIOR should translate");
4538        assert_eq!(event.data, b"\x1b[5~");
4539        assert!(!event.submit);
4540    }
4541
4542    #[test]
4543    #[cfg(windows)]
4544    fn translate_console_key_page_down() {
4545        use winapi::um::winuser::VK_NEXT;
4546        let event = translate_console_key_event(&key_event(VK_NEXT as u16, 0, 0, 1))
4547            .expect("VK_NEXT should translate");
4548        assert_eq!(event.data, b"\x1b[6~");
4549        assert!(!event.submit);
4550    }
4551
4552    #[test]
4553    #[cfg(windows)]
4554    fn translate_console_key_shift_home() {
4555        use winapi::um::winuser::VK_HOME;
4556        let event = translate_console_key_event(&key_event(VK_HOME as u16, 0, SHIFT_PRESSED, 1))
4557            .expect("Shift+Home should translate");
4558        assert_eq!(event.data, b"\x1b[1;2H");
4559        assert!(event.shift);
4560    }
4561
4562    #[test]
4563    #[cfg(windows)]
4564    fn translate_console_key_shift_end() {
4565        use winapi::um::winuser::VK_END;
4566        let event = translate_console_key_event(&key_event(VK_END as u16, 0, SHIFT_PRESSED, 1))
4567            .expect("Shift+End should translate");
4568        assert_eq!(event.data, b"\x1b[1;2F");
4569        assert!(event.shift);
4570    }
4571
4572    #[test]
4573    #[cfg(windows)]
4574    fn translate_console_key_ctrl_home() {
4575        use winapi::um::winuser::VK_HOME;
4576        let event =
4577            translate_console_key_event(&key_event(VK_HOME as u16, 0, LEFT_CTRL_PRESSED, 1))
4578                .expect("Ctrl+Home should translate");
4579        assert_eq!(event.data, b"\x1b[1;5H");
4580        assert!(event.ctrl);
4581    }
4582
4583    #[test]
4584    #[cfg(windows)]
4585    fn translate_console_key_shift_delete() {
4586        use winapi::um::winuser::VK_DELETE;
4587        let event = translate_console_key_event(&key_event(VK_DELETE as u16, 0, SHIFT_PRESSED, 1))
4588            .expect("Shift+Delete should translate");
4589        assert_eq!(event.data, b"\x1b[3;2~");
4590        assert!(event.shift);
4591    }
4592
4593    #[test]
4594    #[cfg(windows)]
4595    fn translate_console_key_ctrl_page_up() {
4596        use winapi::um::winuser::VK_PRIOR;
4597        let event =
4598            translate_console_key_event(&key_event(VK_PRIOR as u16, 0, LEFT_CTRL_PRESSED, 1))
4599                .expect("Ctrl+PageUp should translate");
4600        assert_eq!(event.data, b"\x1b[5;5~");
4601        assert!(event.ctrl);
4602    }
4603
4604    #[test]
4605    #[cfg(windows)]
4606    fn translate_console_key_backspace() {
4607        use winapi::um::winuser::VK_BACK;
4608        let event = translate_console_key_event(&key_event(VK_BACK as u16, 0x08, 0, 1))
4609            .expect("Backspace should translate");
4610        assert_eq!(event.data, b"\x08");
4611    }
4612
4613    #[test]
4614    #[cfg(windows)]
4615    fn translate_console_key_escape() {
4616        use winapi::um::winuser::VK_ESCAPE;
4617        let event = translate_console_key_event(&key_event(VK_ESCAPE as u16, 0x1b, 0, 1))
4618            .expect("Escape should translate");
4619        assert_eq!(event.data, b"\x1b");
4620    }
4621
4622    #[test]
4623    #[cfg(windows)]
4624    fn translate_console_key_tab() {
4625        let event = translate_console_key_event(&key_event(VK_TAB as u16, 0, 0, 1))
4626            .expect("Tab should translate");
4627        assert_eq!(event.data, b"\t");
4628    }
4629
4630    #[test]
4631    #[cfg(windows)]
4632    fn translate_console_key_plain_enter_is_submit() {
4633        let event = translate_console_key_event(&key_event(VK_RETURN as u16, '\r' as u16, 0, 1))
4634            .expect("Enter should translate");
4635        assert_eq!(event.data, b"\r");
4636        assert!(event.submit);
4637        assert!(!event.shift);
4638    }
4639
4640    #[test]
4641    #[cfg(windows)]
4642    fn translate_console_key_unicode_printable() {
4643        // Regular 'a' key
4644        let event = translate_console_key_event(&key_event(b'A' as u16, 'a' as u16, 0, 1))
4645            .expect("printable should translate");
4646        assert_eq!(event.data, b"a");
4647    }
4648
4649    #[test]
4650    #[cfg(windows)]
4651    fn translate_console_key_unicode_repeated() {
4652        let event = translate_console_key_event(&key_event(b'A' as u16, 'a' as u16, 0, 3))
4653            .expect("repeated printable should translate");
4654        assert_eq!(event.data, b"aaa");
4655    }
4656
4657    #[test]
4658    #[cfg(windows)]
4659    fn translate_console_key_down_arrow() {
4660        use winapi::um::winuser::VK_DOWN;
4661        let event = translate_console_key_event(&key_event(VK_DOWN as u16, 0, 0, 1))
4662            .expect("Down arrow should translate");
4663        assert_eq!(event.data, b"\x1b[B");
4664    }
4665
4666    #[test]
4667    #[cfg(windows)]
4668    fn translate_console_key_right_arrow() {
4669        use winapi::um::winuser::VK_RIGHT;
4670        let event = translate_console_key_event(&key_event(VK_RIGHT as u16, 0, 0, 1))
4671            .expect("Right arrow should translate");
4672        assert_eq!(event.data, b"\x1b[C");
4673    }
4674
4675    #[test]
4676    #[cfg(windows)]
4677    fn translate_console_key_left_arrow() {
4678        use winapi::um::winuser::VK_LEFT;
4679        let event = translate_console_key_event(&key_event(VK_LEFT as u16, 0, 0, 1))
4680            .expect("Left arrow should translate");
4681        assert_eq!(event.data, b"\x1b[D");
4682    }
4683
4684    #[test]
4685    #[cfg(windows)]
4686    fn translate_console_key_unknown_vk_no_unicode_returns_none() {
4687        // Unknown VK with no unicode char → should return None
4688        let result = translate_console_key_event(&key_event(0xFF, 0, 0, 1));
4689        assert!(result.is_none());
4690    }
4691
4692    #[test]
4693    #[cfg(windows)]
4694    fn translate_console_key_alt_escape_prefix() {
4695        // Alt+letter should prepend ESC byte to the character
4696        let event =
4697            translate_console_key_event(&key_event(b'A' as u16, 'a' as u16, LEFT_ALT_PRESSED, 1))
4698                .expect("Alt+a should translate");
4699        assert_eq!(event.data, b"\x1ba");
4700        assert!(event.alt);
4701    }
4702
4703    #[test]
4704    #[cfg(windows)]
4705    fn translate_console_key_ctrl_a() {
4706        let event =
4707            translate_console_key_event(&key_event(b'A' as u16, 'a' as u16, LEFT_CTRL_PRESSED, 1))
4708                .expect("Ctrl+A should translate");
4709        assert_eq!(event.data, [0x01]); // SOH
4710        assert!(event.ctrl);
4711    }
4712
4713    #[test]
4714    #[cfg(windows)]
4715    fn translate_console_key_ctrl_z() {
4716        let event =
4717            translate_console_key_event(&key_event(b'Z' as u16, 'z' as u16, LEFT_CTRL_PRESSED, 1))
4718                .expect("Ctrl+Z should translate");
4719        assert_eq!(event.data, [0x1A]); // SUB
4720        assert!(event.ctrl);
4721    }
4722
4723    // ── NativeSignalBool tests (no PyO3 needed) ──
4724
4725    #[test]
4726    fn signal_bool_default_false() {
4727        let sb = NativeSignalBool::new(false);
4728        assert!(!sb.load_nolock());
4729    }
4730
4731    #[test]
4732    fn signal_bool_default_true() {
4733        let sb = NativeSignalBool::new(true);
4734        assert!(sb.load_nolock());
4735    }
4736
4737    #[test]
4738    fn signal_bool_store_and_load() {
4739        let sb = NativeSignalBool::new(false);
4740        sb.store_locked(true);
4741        assert!(sb.load_nolock());
4742        sb.store_locked(false);
4743        assert!(!sb.load_nolock());
4744    }
4745
4746    #[test]
4747    fn signal_bool_compare_and_swap_success() {
4748        let sb = NativeSignalBool::new(false);
4749        assert!(sb.compare_and_swap_locked(false, true));
4750        assert!(sb.load_nolock());
4751    }
4752
4753    #[test]
4754    fn signal_bool_compare_and_swap_failure() {
4755        let sb = NativeSignalBool::new(false);
4756        assert!(!sb.compare_and_swap_locked(true, false));
4757        assert!(!sb.load_nolock());
4758    }
4759
4760    // ── NativePtyBuffer tests (non-Python methods) ──
4761
4762    #[test]
4763    fn pty_buffer_available_empty() {
4764        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4765        assert!(!buf.available());
4766    }
4767
4768    #[test]
4769    fn pty_buffer_record_and_available() {
4770        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4771        buf.record_output(b"hello");
4772        assert!(buf.available());
4773    }
4774
4775    #[test]
4776    fn pty_buffer_history_bytes_and_clear() {
4777        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4778        buf.record_output(b"hello");
4779        buf.record_output(b"world");
4780        assert_eq!(buf.history_bytes(), 10);
4781        let released = buf.clear_history();
4782        assert_eq!(released, 10);
4783        assert_eq!(buf.history_bytes(), 0);
4784    }
4785
4786    #[test]
4787    fn pty_buffer_close() {
4788        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4789        buf.close();
4790        // After close, buffer is marked as closed
4791        // (no panic, graceful handling)
4792    }
4793
4794    // ── NativePtyBuffer tests with PyO3 ──
4795
4796    #[test]
4797    fn pty_buffer_drain_returns_recorded_chunks() {
4798        pyo3::prepare_freethreaded_python();
4799        pyo3::Python::with_gil(|py| {
4800            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4801            buf.record_output(b"chunk1");
4802            buf.record_output(b"chunk2");
4803            let drained = buf.drain(py).unwrap();
4804            assert_eq!(drained.len(), 2);
4805            assert!(!buf.available());
4806        });
4807    }
4808
4809    #[test]
4810    fn pty_buffer_output_returns_full_history() {
4811        pyo3::prepare_freethreaded_python();
4812        pyo3::Python::with_gil(|py| {
4813            let buf = NativePtyBuffer::new(true, "utf-8", "replace");
4814            buf.record_output(b"hello ");
4815            buf.record_output(b"world");
4816            let output = buf.output(py).unwrap();
4817            let text: String = output.extract(py).unwrap();
4818            assert_eq!(text, "hello world");
4819        });
4820    }
4821
4822    #[test]
4823    fn pty_buffer_output_since_offset() {
4824        pyo3::prepare_freethreaded_python();
4825        pyo3::Python::with_gil(|py| {
4826            let buf = NativePtyBuffer::new(true, "utf-8", "replace");
4827            buf.record_output(b"hello ");
4828            buf.record_output(b"world");
4829            let output = buf.output_since(py, 6).unwrap();
4830            let text: String = output.extract(py).unwrap();
4831            assert_eq!(text, "world");
4832        });
4833    }
4834
4835    #[test]
4836    fn pty_buffer_read_non_blocking_empty() {
4837        pyo3::prepare_freethreaded_python();
4838        pyo3::Python::with_gil(|py| {
4839            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4840            let result = buf.read_non_blocking(py).unwrap();
4841            assert!(result.is_none());
4842        });
4843    }
4844
4845    #[test]
4846    fn pty_buffer_read_non_blocking_with_data() {
4847        pyo3::prepare_freethreaded_python();
4848        pyo3::Python::with_gil(|py| {
4849            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4850            buf.record_output(b"data");
4851            let result = buf.read_non_blocking(py).unwrap();
4852            assert!(result.is_some());
4853        });
4854    }
4855
4856    #[test]
4857    fn pty_buffer_read_closed_returns_error() {
4858        pyo3::prepare_freethreaded_python();
4859        pyo3::Python::with_gil(|py| {
4860            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4861            buf.close();
4862            let result = buf.read_non_blocking(py);
4863            assert!(result.is_err());
4864        });
4865    }
4866
4867    #[test]
4868    fn pty_buffer_read_with_timeout() {
4869        pyo3::prepare_freethreaded_python();
4870        pyo3::Python::with_gil(|py| {
4871            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4872            let result = buf.read(py, Some(0.05));
4873            // Should timeout since no data
4874            assert!(result.is_err());
4875        });
4876    }
4877
4878    #[test]
4879    fn pty_buffer_text_mode_decodes_utf8() {
4880        pyo3::prepare_freethreaded_python();
4881        pyo3::Python::with_gil(|py| {
4882            let buf = NativePtyBuffer::new(true, "utf-8", "replace");
4883            buf.record_output("héllo".as_bytes());
4884            let result = buf.read_non_blocking(py).unwrap().unwrap();
4885            let text: String = result.extract(py).unwrap();
4886            assert_eq!(text, "héllo");
4887        });
4888    }
4889
4890    #[test]
4891    fn pty_buffer_bytes_mode_returns_bytes() {
4892        pyo3::prepare_freethreaded_python();
4893        pyo3::Python::with_gil(|py| {
4894            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
4895            buf.record_output(b"\xff\xfe");
4896            let result = buf.read_non_blocking(py).unwrap().unwrap();
4897            let bytes: Vec<u8> = result.extract(py).unwrap();
4898            assert_eq!(bytes, vec![0xff, 0xfe]);
4899        });
4900    }
4901
4902    // ── NativeIdleDetector tests (requires PyO3) ──
4903
4904    fn make_idle_detector(
4905        py: pyo3::Python<'_>,
4906        timeout_seconds: f64,
4907        enabled: bool,
4908        initial_idle_for: f64,
4909    ) -> NativeIdleDetector {
4910        let signal = pyo3::Py::new(py, NativeSignalBool::new(enabled)).unwrap();
4911        NativeIdleDetector::new(
4912            py,
4913            timeout_seconds,
4914            0.0,  // stability_window_seconds
4915            0.01, // sample_interval_seconds
4916            signal,
4917            true, // reset_on_input
4918            true, // reset_on_output
4919            true, // count_control_churn_as_output
4920            initial_idle_for,
4921        )
4922    }
4923
4924    #[test]
4925    fn idle_detector_mark_exit_returns_process_exit() {
4926        pyo3::prepare_freethreaded_python();
4927        pyo3::Python::with_gil(|py| {
4928            let det = make_idle_detector(py, 10.0, true, 0.0);
4929            det.mark_exit(42, false);
4930            let (triggered, reason, _idle_for, returncode) = det.wait(py, Some(1.0));
4931            assert!(!triggered);
4932            assert_eq!(reason, "process_exit");
4933            assert_eq!(returncode, Some(42));
4934        });
4935    }
4936
4937    #[test]
4938    fn idle_detector_mark_exit_interrupted() {
4939        pyo3::prepare_freethreaded_python();
4940        pyo3::Python::with_gil(|py| {
4941            let det = make_idle_detector(py, 10.0, true, 0.0);
4942            det.mark_exit(1, true);
4943            let (triggered, reason, _idle_for, returncode) = det.wait(py, Some(1.0));
4944            assert!(!triggered);
4945            assert_eq!(reason, "interrupt");
4946            assert_eq!(returncode, Some(1));
4947        });
4948    }
4949
4950    #[test]
4951    fn idle_detector_timeout_when_not_idle() {
4952        pyo3::prepare_freethreaded_python();
4953        pyo3::Python::with_gil(|py| {
4954            let det = make_idle_detector(py, 10.0, true, 0.0);
4955            let (triggered, reason, _idle_for, returncode) = det.wait(py, Some(0.05));
4956            assert!(!triggered);
4957            assert_eq!(reason, "timeout");
4958            assert!(returncode.is_none());
4959        });
4960    }
4961
4962    #[test]
4963    fn idle_detector_triggers_when_already_idle() {
4964        pyo3::prepare_freethreaded_python();
4965        pyo3::Python::with_gil(|py| {
4966            // initial_idle_for=1.0 means it thinks it's been idle for 1 second
4967            // timeout_seconds=0.5 means 0.5s idle triggers
4968            let det = make_idle_detector(py, 0.5, true, 1.0);
4969            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(1.0));
4970            assert!(triggered);
4971            assert_eq!(reason, "idle_timeout");
4972        });
4973    }
4974
4975    #[test]
4976    fn idle_detector_disabled_does_not_trigger() {
4977        pyo3::prepare_freethreaded_python();
4978        pyo3::Python::with_gil(|py| {
4979            let det = make_idle_detector(py, 0.01, false, 1.0);
4980            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(0.1));
4981            assert!(!triggered);
4982            assert_eq!(reason, "timeout");
4983        });
4984    }
4985
4986    #[test]
4987    fn idle_detector_record_input_resets_idle() {
4988        pyo3::prepare_freethreaded_python();
4989        pyo3::Python::with_gil(|py| {
4990            let det = make_idle_detector(py, 0.5, true, 1.0);
4991            // Recording input should reset the idle timer
4992            det.record_input(5);
4993            // Now it should NOT trigger immediately since we just reset
4994            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(0.05));
4995            assert!(!triggered);
4996            assert_eq!(reason, "timeout");
4997        });
4998    }
4999
5000    #[test]
5001    fn idle_detector_record_output_resets_idle() {
5002        pyo3::prepare_freethreaded_python();
5003        pyo3::Python::with_gil(|py| {
5004            let det = make_idle_detector(py, 0.5, true, 1.0);
5005            // Recording visible output should reset idle timer
5006            det.record_output(b"visible output");
5007            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(0.05));
5008            assert!(!triggered);
5009            assert_eq!(reason, "timeout");
5010        });
5011    }
5012
5013    #[test]
5014    fn idle_detector_control_churn_only_no_reset_when_not_counted() {
5015        pyo3::prepare_freethreaded_python();
5016        pyo3::Python::with_gil(|py| {
5017            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5018            let det = NativeIdleDetector::new(
5019                py, 0.05, 0.0, 0.01, signal, true, true,
5020                false, // count_control_churn_as_output = false
5021                1.0,   // already idle for 1s
5022            );
5023            // Output only ANSI escape (no visible content)
5024            det.record_output(b"\x1b[31m");
5025            // Should still trigger because control churn doesn't count
5026            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(0.5));
5027            assert!(triggered);
5028            assert_eq!(reason, "idle_timeout");
5029        });
5030    }
5031
5032    // ── Process tracking tests (requires PyO3) ──
5033
5034    #[test]
5035    fn process_registry_register_list_unregister() {
5036        pyo3::prepare_freethreaded_python();
5037        pyo3::Python::with_gil(|_py| {
5038            let test_pid = 99999u32;
5039            // Register
5040            native_register_process(test_pid, "test", "test-command", None).unwrap();
5041            // List
5042            let list = native_list_active_processes();
5043            let found = list.iter().any(|(pid, _, _, _, _)| *pid == test_pid);
5044            assert!(found, "registered pid should appear in active list");
5045            // Unregister
5046            native_unregister_process(test_pid).unwrap();
5047            let list = native_list_active_processes();
5048            let found = list.iter().any(|(pid, _, _, _, _)| *pid == test_pid);
5049            assert!(!found, "unregistered pid should not appear in active list");
5050        });
5051    }
5052
5053    // ── NativeProcessMetrics tests (requires PyO3) ──
5054
5055    #[test]
5056    fn process_metrics_sample_current_process() {
5057        let pid = std::process::id();
5058        let metrics = NativeProcessMetrics::new(pid);
5059        metrics.prime();
5060        let (exists, _cpu, _disk, _extra) = metrics.sample();
5061        assert!(exists, "current process should exist");
5062    }
5063
5064    #[test]
5065    fn process_metrics_nonexistent_process() {
5066        let metrics = NativeProcessMetrics::new(99999999);
5067        metrics.prime();
5068        let (exists, _cpu, _disk, _extra) = metrics.sample();
5069        assert!(!exists, "nonexistent pid should not exist");
5070    }
5071
5072    // ── portable_exit_code tests ──
5073
5074    #[test]
5075    fn portable_exit_code_normal_exit_zero() {
5076        let status = portable_pty::ExitStatus::with_exit_code(0);
5077        assert_eq!(portable_exit_code(status), 0);
5078    }
5079
5080    #[test]
5081    fn portable_exit_code_normal_exit_nonzero() {
5082        let status = portable_pty::ExitStatus::with_exit_code(42);
5083        assert_eq!(portable_exit_code(status), 42);
5084    }
5085
5086    // ── record_pty_input_metrics tests ──
5087
5088    #[test]
5089    fn record_pty_input_metrics_basic() {
5090        let input_bytes = Arc::new(AtomicUsize::new(0));
5091        let newline_events = Arc::new(AtomicUsize::new(0));
5092        let submit_events = Arc::new(AtomicUsize::new(0));
5093
5094        record_pty_input_metrics(
5095            &input_bytes,
5096            &newline_events,
5097            &submit_events,
5098            b"hello",
5099            false,
5100        );
5101
5102        assert_eq!(input_bytes.load(Ordering::Acquire), 5);
5103        assert_eq!(newline_events.load(Ordering::Acquire), 0);
5104        assert_eq!(submit_events.load(Ordering::Acquire), 0);
5105    }
5106
5107    #[test]
5108    fn record_pty_input_metrics_with_newline() {
5109        let input_bytes = Arc::new(AtomicUsize::new(0));
5110        let newline_events = Arc::new(AtomicUsize::new(0));
5111        let submit_events = Arc::new(AtomicUsize::new(0));
5112
5113        record_pty_input_metrics(
5114            &input_bytes,
5115            &newline_events,
5116            &submit_events,
5117            b"hello\n",
5118            false,
5119        );
5120
5121        assert_eq!(input_bytes.load(Ordering::Acquire), 6);
5122        assert_eq!(newline_events.load(Ordering::Acquire), 1);
5123        assert_eq!(submit_events.load(Ordering::Acquire), 0);
5124    }
5125
5126    #[test]
5127    fn record_pty_input_metrics_with_submit() {
5128        let input_bytes = Arc::new(AtomicUsize::new(0));
5129        let newline_events = Arc::new(AtomicUsize::new(0));
5130        let submit_events = Arc::new(AtomicUsize::new(0));
5131
5132        record_pty_input_metrics(&input_bytes, &newline_events, &submit_events, b"\r", true);
5133
5134        assert_eq!(input_bytes.load(Ordering::Acquire), 1);
5135        assert_eq!(newline_events.load(Ordering::Acquire), 1);
5136        assert_eq!(submit_events.load(Ordering::Acquire), 1);
5137    }
5138
5139    #[test]
5140    fn record_pty_input_metrics_accumulates() {
5141        let input_bytes = Arc::new(AtomicUsize::new(0));
5142        let newline_events = Arc::new(AtomicUsize::new(0));
5143        let submit_events = Arc::new(AtomicUsize::new(0));
5144
5145        record_pty_input_metrics(&input_bytes, &newline_events, &submit_events, b"ab", false);
5146        record_pty_input_metrics(&input_bytes, &newline_events, &submit_events, b"cd\n", true);
5147
5148        assert_eq!(input_bytes.load(Ordering::Acquire), 5);
5149        assert_eq!(newline_events.load(Ordering::Acquire), 1);
5150        assert_eq!(submit_events.load(Ordering::Acquire), 1);
5151    }
5152
5153    // ── store_pty_returncode tests ──
5154
5155    #[test]
5156    fn store_pty_returncode_sets_value() {
5157        let returncode = Arc::new(Mutex::new(None));
5158        store_pty_returncode(&returncode, 42);
5159        assert_eq!(*returncode.lock().unwrap(), Some(42));
5160    }
5161
5162    #[test]
5163    fn store_pty_returncode_overwrites() {
5164        let returncode = Arc::new(Mutex::new(Some(1)));
5165        store_pty_returncode(&returncode, 0);
5166        assert_eq!(*returncode.lock().unwrap(), Some(0));
5167    }
5168
5169    // ── write_pty_input error path tests ──
5170
5171    #[test]
5172    fn write_pty_input_not_connected() {
5173        let handles: Arc<Mutex<Option<NativePtyHandles>>> = Arc::new(Mutex::new(None));
5174        let result = write_pty_input(&handles, b"hello");
5175        assert!(result.is_err());
5176        let err = result.unwrap_err();
5177        assert_eq!(err.kind(), std::io::ErrorKind::NotConnected);
5178    }
5179
5180    // ── poll_pty_process tests ──
5181
5182    #[test]
5183    fn poll_pty_process_no_handles_returns_stored_code() {
5184        let handles: Arc<Mutex<Option<NativePtyHandles>>> = Arc::new(Mutex::new(None));
5185        let returncode = Arc::new(Mutex::new(Some(42)));
5186        let result = poll_pty_process(&handles, &returncode).unwrap();
5187        assert_eq!(result, Some(42));
5188    }
5189
5190    #[test]
5191    fn poll_pty_process_no_handles_no_code() {
5192        let handles: Arc<Mutex<Option<NativePtyHandles>>> = Arc::new(Mutex::new(None));
5193        let returncode = Arc::new(Mutex::new(None));
5194        let result = poll_pty_process(&handles, &returncode).unwrap();
5195        assert_eq!(result, None);
5196    }
5197
5198    // ── descendant_pids tests ──
5199
5200    #[test]
5201    fn descendant_pids_returns_empty_for_unknown_pid() {
5202        let system = System::new();
5203        let pid = system_pid(99999999);
5204        let descendants = descendant_pids(&system, pid);
5205        assert!(descendants.is_empty());
5206    }
5207
5208    // ── unix_now_seconds tests ──
5209
5210    #[test]
5211    fn unix_now_seconds_returns_positive() {
5212        let now = unix_now_seconds();
5213        assert!(now > 0.0, "unix timestamp should be positive");
5214    }
5215
5216    // ── same_process_identity tests ──
5217
5218    #[test]
5219    fn same_process_identity_nonexistent_pid() {
5220        assert!(!same_process_identity(99999999, 0.0, 1.0));
5221    }
5222
5223    // ── tracked_process_db_path tests ──
5224
5225    #[test]
5226    fn tracked_process_db_path_returns_ok() {
5227        let path = tracked_process_db_path();
5228        assert!(path.is_ok());
5229        let path = path.unwrap();
5230        assert!(
5231            path.to_string_lossy().contains("tracked-pids.sqlite3"),
5232            "path should contain expected filename: {:?}",
5233            path
5234        );
5235    }
5236
5237    // ── command_builder_from_argv tests ──
5238
5239    #[test]
5240    fn command_builder_from_argv_single_arg() {
5241        let argv = vec!["echo".to_string()];
5242        let _cmd = command_builder_from_argv(&argv);
5243        // Just ensure it doesn't panic
5244    }
5245
5246    #[test]
5247    fn command_builder_from_argv_multi_args() {
5248        let argv = vec!["echo".to_string(), "hello".to_string(), "world".to_string()];
5249        let _cmd = command_builder_from_argv(&argv);
5250        // Just ensure it doesn't panic
5251    }
5252
5253    // ── process_err_to_py tests ──
5254
5255    #[test]
5256    fn process_err_to_py_timeout() {
5257        pyo3::prepare_freethreaded_python();
5258        pyo3::Python::with_gil(|py| {
5259            let err = process_err_to_py(ProcessError::Timeout);
5260            assert!(err.is_instance_of::<pyo3::exceptions::PyTimeoutError>(py));
5261        });
5262    }
5263
5264    // ── kill_process_tree_impl tests ──
5265
5266    #[test]
5267    fn kill_process_tree_nonexistent_pid_no_panic() {
5268        // Should not panic when given a PID that doesn't exist
5269        kill_process_tree_impl(99999999, 0.1);
5270    }
5271
5272    // ── NativeIdleDetector additional tests ──
5273
5274    #[test]
5275    fn idle_detector_record_input_zero_bytes_no_reset() {
5276        pyo3::prepare_freethreaded_python();
5277        pyo3::Python::with_gil(|py| {
5278            let det = make_idle_detector(py, 0.05, true, 1.0);
5279            // Recording 0 bytes should NOT reset idle timer
5280            det.record_input(0);
5281            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(0.5));
5282            assert!(triggered);
5283            assert_eq!(reason, "idle_timeout");
5284        });
5285    }
5286
5287    #[test]
5288    fn idle_detector_record_output_empty_no_reset() {
5289        pyo3::prepare_freethreaded_python();
5290        pyo3::Python::with_gil(|py| {
5291            let det = make_idle_detector(py, 0.05, true, 1.0);
5292            // Recording empty output should NOT reset idle timer
5293            det.record_output(b"");
5294            let (triggered, reason, _idle_for, _returncode) = det.wait(py, Some(0.5));
5295            assert!(triggered);
5296            assert_eq!(reason, "idle_timeout");
5297        });
5298    }
5299
5300    #[test]
5301    fn idle_detector_enabled_getter_and_setter() {
5302        pyo3::prepare_freethreaded_python();
5303        pyo3::Python::with_gil(|py| {
5304            let det = make_idle_detector(py, 1.0, true, 0.0);
5305            assert!(det.enabled());
5306            det.set_enabled(false);
5307            assert!(!det.enabled());
5308            det.set_enabled(true);
5309            assert!(det.enabled());
5310        });
5311    }
5312
5313    // ── NativePtyBuffer additional tests ──
5314
5315    #[test]
5316    fn pty_buffer_multiple_record_and_drain() {
5317        pyo3::prepare_freethreaded_python();
5318        pyo3::Python::with_gil(|py| {
5319            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
5320            buf.record_output(b"a");
5321            buf.record_output(b"b");
5322            buf.record_output(b"c");
5323            let drained = buf.drain(py).unwrap();
5324            assert_eq!(drained.len(), 3);
5325            assert!(!buf.available());
5326            // history should still be available
5327            assert_eq!(buf.history_bytes(), 3);
5328        });
5329    }
5330
5331    #[test]
5332    fn pty_buffer_output_since_beyond_length() {
5333        pyo3::prepare_freethreaded_python();
5334        pyo3::Python::with_gil(|py| {
5335            let buf = NativePtyBuffer::new(true, "utf-8", "replace");
5336            buf.record_output(b"hi");
5337            let output = buf.output_since(py, 999).unwrap();
5338            let text: String = output.extract(py).unwrap();
5339            assert_eq!(text, "");
5340        });
5341    }
5342
5343    #[test]
5344    fn pty_buffer_clear_history_returns_correct_bytes() {
5345        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
5346        buf.record_output(b"hello");
5347        buf.record_output(b"world");
5348        assert_eq!(buf.history_bytes(), 10);
5349        let released = buf.clear_history();
5350        assert_eq!(released, 10);
5351        assert_eq!(buf.history_bytes(), 0);
5352        // Record more after clear
5353        buf.record_output(b"new");
5354        assert_eq!(buf.history_bytes(), 3);
5355    }
5356
5357    // ── NativeSignalBool additional tests ──
5358
5359    #[test]
5360    fn signal_bool_concurrent_access() {
5361        let sb = NativeSignalBool::new(false);
5362        let sb_clone = sb.clone();
5363
5364        let handle = std::thread::spawn(move || {
5365            sb_clone.store_locked(true);
5366        });
5367        handle.join().unwrap();
5368        assert!(sb.load_nolock());
5369    }
5370
5371    // ── control_churn_bytes additional edge cases ──
5372
5373    #[test]
5374    fn control_churn_bytes_escape_then_non_bracket() {
5375        // ESC followed by non-bracket character: only ESC itself is churn
5376        assert_eq!(control_churn_bytes(b"\x1bO"), 1);
5377    }
5378
5379    #[test]
5380    fn control_churn_bytes_incomplete_csi() {
5381        // ESC [ without terminator - counts entire remainder as churn
5382        assert_eq!(control_churn_bytes(b"\x1b[123"), 5);
5383    }
5384
5385    #[test]
5386    fn control_churn_bytes_multiple_sequences() {
5387        // Two complete CSI sequences
5388        assert_eq!(control_churn_bytes(b"\x1b[H\x1b[2J"), 7);
5389    }
5390
5391    // ── Windows-specific additional tests ──
5392
5393    #[cfg(windows)]
5394    mod windows_payload_tests {
5395        use super::*;
5396
5397        #[test]
5398        fn windows_terminal_input_payload_mixed_line_endings() {
5399            let result = windows_terminal_input_payload(b"a\nb\r\nc\rd");
5400            assert_eq!(result, b"a\rb\r\nc\rd");
5401        }
5402
5403        #[test]
5404        fn windows_terminal_input_payload_consecutive_lf() {
5405            let result = windows_terminal_input_payload(b"\n\n");
5406            assert_eq!(result, b"\r\r");
5407        }
5408
5409        #[test]
5410        fn windows_terminal_input_payload_empty() {
5411            let result = windows_terminal_input_payload(b"");
5412            assert!(result.is_empty());
5413        }
5414
5415        #[test]
5416        fn windows_terminal_input_payload_no_line_endings() {
5417            let result = windows_terminal_input_payload(b"hello world");
5418            assert_eq!(result, b"hello world");
5419        }
5420
5421        #[test]
5422        fn format_terminal_input_bytes_single() {
5423            assert_eq!(format_terminal_input_bytes(&[0x0D]), "[0d]");
5424        }
5425
5426        #[test]
5427        fn native_terminal_input_mode_preserves_other_flags() {
5428            // Pass a mode with an unrelated flag set
5429            let custom_flag = 0x0100; // some arbitrary flag
5430            let result = native_terminal_input_mode(custom_flag);
5431            // The custom flag should be preserved
5432            assert_ne!(result & custom_flag, 0);
5433        }
5434    }
5435
5436    // ── Process registry additional tests ──
5437
5438    #[test]
5439    fn process_registry_register_with_cwd() {
5440        pyo3::prepare_freethreaded_python();
5441        pyo3::Python::with_gil(|_py| {
5442            let test_pid = 99998u32;
5443            native_register_process(test_pid, "test", "test-cmd", Some("/tmp/test".to_string()))
5444                .unwrap();
5445            let list = native_list_active_processes();
5446            let entry = list.iter().find(|(pid, _, _, _, _)| *pid == test_pid);
5447            assert!(entry.is_some());
5448            let (_, kind, cmd, cwd, _) = entry.unwrap();
5449            assert_eq!(kind, "test");
5450            assert_eq!(cmd, "test-cmd");
5451            assert_eq!(cwd.as_deref(), Some("/tmp/test"));
5452            native_unregister_process(test_pid).unwrap();
5453        });
5454    }
5455
5456    #[test]
5457    fn process_registry_double_register_overwrites() {
5458        pyo3::prepare_freethreaded_python();
5459        pyo3::Python::with_gil(|_py| {
5460            let test_pid = 99997u32;
5461            native_register_process(test_pid, "first", "cmd1", None).unwrap();
5462            native_register_process(test_pid, "second", "cmd2", None).unwrap();
5463            let list = native_list_active_processes();
5464            let entries: Vec<_> = list
5465                .iter()
5466                .filter(|(pid, _, _, _, _)| *pid == test_pid)
5467                .collect();
5468            assert_eq!(entries.len(), 1);
5469            assert_eq!(entries[0].1, "second");
5470            native_unregister_process(test_pid).unwrap();
5471        });
5472    }
5473
5474    #[test]
5475    fn process_registry_unregister_nonexistent_no_error() {
5476        pyo3::prepare_freethreaded_python();
5477        pyo3::Python::with_gil(|_py| {
5478            // Should not error when unregistering a PID that doesn't exist
5479            let result = native_unregister_process(99996);
5480            assert!(result.is_ok());
5481        });
5482    }
5483
5484    // ── list_tracked_processes tests ──
5485
5486    #[test]
5487    fn list_tracked_processes_returns_ok() {
5488        pyo3::prepare_freethreaded_python();
5489        pyo3::Python::with_gil(|_py| {
5490            let result = list_tracked_processes();
5491            assert!(result.is_ok());
5492        });
5493    }
5494
5495    // ══════════════════════════════════════════════════════════════
5496    // Iteration 2: Additional coverage tests
5497    // ══════════════════════════════════════════════════════════════
5498
5499    // ── is_ignorable_process_control_error additional tests ──
5500
5501    #[test]
5502    fn non_ignorable_error_connection_refused() {
5503        let err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
5504        assert!(!is_ignorable_process_control_error(&err));
5505    }
5506
5507    // ── to_py_err tests ──
5508
5509    #[test]
5510    fn to_py_err_creates_runtime_error() {
5511        pyo3::prepare_freethreaded_python();
5512        let err = to_py_err("test error message");
5513        assert!(err.to_string().contains("test error message"));
5514    }
5515
5516    // ── process_err_to_py tests ──
5517
5518    #[test]
5519    fn process_err_to_py_timeout_is_timeout_error() {
5520        pyo3::prepare_freethreaded_python();
5521        let err = process_err_to_py(running_process_core::ProcessError::Timeout);
5522        pyo3::Python::with_gil(|py| {
5523            assert!(err.is_instance_of::<pyo3::exceptions::PyTimeoutError>(py));
5524        });
5525    }
5526
5527    #[test]
5528    fn process_err_to_py_not_running_is_runtime_error() {
5529        pyo3::prepare_freethreaded_python();
5530        let err = process_err_to_py(running_process_core::ProcessError::NotRunning);
5531        pyo3::Python::with_gil(|py| {
5532            assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
5533        });
5534    }
5535
5536    // ── input_contains_newline tests ──
5537
5538    #[test]
5539    fn input_contains_newline_with_cr() {
5540        assert!(input_contains_newline(b"hello\rworld"));
5541    }
5542
5543    #[test]
5544    fn input_contains_newline_with_lf() {
5545        assert!(input_contains_newline(b"hello\nworld"));
5546    }
5547
5548    #[test]
5549    fn input_contains_newline_with_crlf() {
5550        assert!(input_contains_newline(b"hello\r\nworld"));
5551    }
5552
5553    #[test]
5554    fn input_contains_newline_without_newline() {
5555        assert!(!input_contains_newline(b"hello world"));
5556    }
5557
5558    // ── control_churn_bytes additional tests (iter2) ──
5559
5560    #[test]
5561    fn control_churn_bytes_backspace() {
5562        assert_eq!(control_churn_bytes(b"\x08"), 1);
5563    }
5564
5565    #[test]
5566    fn control_churn_bytes_carriage_return() {
5567        assert_eq!(control_churn_bytes(b"\x0D"), 1);
5568    }
5569
5570    #[test]
5571    fn control_churn_bytes_delete_char() {
5572        assert_eq!(control_churn_bytes(b"\x7F"), 1);
5573    }
5574
5575    #[test]
5576    fn control_churn_bytes_mixed_with_text() {
5577        assert_eq!(control_churn_bytes(b"hello\x0D\x1b[H"), 4);
5578    }
5579
5580    #[test]
5581    fn control_churn_bytes_plain_text_no_churn() {
5582        assert_eq!(control_churn_bytes(b"hello world"), 0);
5583    }
5584
5585    // ── system_pid tests ──
5586
5587    #[test]
5588    fn system_pid_converts_u32() {
5589        let pid = system_pid(12345);
5590        assert_eq!(pid.as_u32(), 12345);
5591    }
5592
5593    // ── unix_now_seconds tests ──
5594
5595    #[test]
5596    fn unix_now_seconds_is_recent() {
5597        let now = unix_now_seconds();
5598        assert!(now > 1_577_836_800.0);
5599    }
5600
5601    // ── NativeIdleDetector: additional wait/record scenarios ──
5602
5603    #[test]
5604    fn idle_detector_wait_idle_timeout_with_initial_idle() {
5605        pyo3::prepare_freethreaded_python();
5606        pyo3::Python::with_gil(|py| {
5607            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5608            let detector =
5609                NativeIdleDetector::new(py, 0.01, 0.01, 0.001, signal, true, true, true, 100.0);
5610            let (idle, reason, _, code) = detector.wait(py, Some(1.0));
5611            assert!(idle);
5612            assert_eq!(reason, "idle_timeout");
5613            assert!(code.is_none());
5614        });
5615    }
5616
5617    #[test]
5618    fn idle_detector_record_output_only_control_churn_with_flag() {
5619        pyo3::prepare_freethreaded_python();
5620        pyo3::Python::with_gil(|py| {
5621            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5622            let detector =
5623                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, true, true, true, 5.0);
5624            let state_before = detector.core.state.lock().unwrap().last_reset_at;
5625            std::thread::sleep(std::time::Duration::from_millis(10));
5626            detector.record_output(b"\x1b[H");
5627            let state_after = detector.core.state.lock().unwrap().last_reset_at;
5628            assert!(state_after > state_before);
5629        });
5630    }
5631
5632    #[test]
5633    fn idle_detector_record_output_only_control_churn_without_flag() {
5634        pyo3::prepare_freethreaded_python();
5635        pyo3::Python::with_gil(|py| {
5636            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5637            let detector =
5638                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, true, true, false, 5.0);
5639            let state_before = detector.core.state.lock().unwrap().last_reset_at;
5640            std::thread::sleep(std::time::Duration::from_millis(10));
5641            detector.record_output(b"\x1b[H");
5642            let state_after = detector.core.state.lock().unwrap().last_reset_at;
5643            assert_eq!(state_before, state_after);
5644        });
5645    }
5646
5647    #[test]
5648    fn idle_detector_record_output_not_enabled() {
5649        pyo3::prepare_freethreaded_python();
5650        pyo3::Python::with_gil(|py| {
5651            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5652            let detector =
5653                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, true, false, true, 5.0);
5654            let state_before = detector.core.state.lock().unwrap().last_reset_at;
5655            std::thread::sleep(std::time::Duration::from_millis(10));
5656            detector.record_output(b"visible");
5657            let state_after = detector.core.state.lock().unwrap().last_reset_at;
5658            assert_eq!(state_before, state_after);
5659        });
5660    }
5661
5662    #[test]
5663    fn idle_detector_record_input_not_enabled() {
5664        pyo3::prepare_freethreaded_python();
5665        pyo3::Python::with_gil(|py| {
5666            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5667            let detector =
5668                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, false, true, true, 5.0);
5669            let state_before = detector.core.state.lock().unwrap().last_reset_at;
5670            std::thread::sleep(std::time::Duration::from_millis(10));
5671            detector.record_input(100);
5672            let state_after = detector.core.state.lock().unwrap().last_reset_at;
5673            assert_eq!(state_before, state_after);
5674        });
5675    }
5676
5677    #[test]
5678    fn idle_detector_record_input_nonzero_bytes_resets() {
5679        pyo3::prepare_freethreaded_python();
5680        pyo3::Python::with_gil(|py| {
5681            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5682            let detector =
5683                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, true, true, true, 5.0);
5684            let state_before = detector.core.state.lock().unwrap().last_reset_at;
5685            std::thread::sleep(std::time::Duration::from_millis(10));
5686            detector.record_input(100);
5687            let state_after = detector.core.state.lock().unwrap().last_reset_at;
5688            assert!(state_after > state_before);
5689        });
5690    }
5691
5692    #[test]
5693    fn idle_detector_record_output_visible_resets() {
5694        pyo3::prepare_freethreaded_python();
5695        pyo3::Python::with_gil(|py| {
5696            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5697            let detector =
5698                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, true, true, true, 5.0);
5699            let state_before = detector.core.state.lock().unwrap().last_reset_at;
5700            std::thread::sleep(std::time::Duration::from_millis(10));
5701            detector.record_output(b"visible output");
5702            let state_after = detector.core.state.lock().unwrap().last_reset_at;
5703            assert!(state_after > state_before);
5704        });
5705    }
5706
5707    #[test]
5708    fn idle_detector_mark_exit_sets_returncode() {
5709        pyo3::prepare_freethreaded_python();
5710        pyo3::Python::with_gil(|py| {
5711            let signal = pyo3::Py::new(py, NativeSignalBool::new(true)).unwrap();
5712            let detector =
5713                NativeIdleDetector::new(py, 1.0, 0.5, 0.1, signal, true, true, true, 0.0);
5714            detector.mark_exit(42, false);
5715            let state = detector.core.state.lock().unwrap();
5716            assert_eq!(state.returncode, Some(42));
5717            assert!(!state.interrupted);
5718        });
5719    }
5720
5721    // ── find_expect_match tests ──
5722
5723    #[test]
5724    fn find_expect_match_literal_found() {
5725        pyo3::prepare_freethreaded_python();
5726        pyo3::Python::with_gil(|py| {
5727            let process = make_test_running_process(py);
5728            let result = process
5729                .find_expect_match("hello world", "world", false)
5730                .unwrap();
5731            assert!(result.is_some());
5732            let (matched, start, end, groups) = result.unwrap();
5733            assert_eq!(matched, "world");
5734            assert_eq!(start, 6);
5735            assert_eq!(end, 11);
5736            assert!(groups.is_empty());
5737        });
5738    }
5739
5740    #[test]
5741    fn find_expect_match_literal_not_found() {
5742        pyo3::prepare_freethreaded_python();
5743        pyo3::Python::with_gil(|py| {
5744            let process = make_test_running_process(py);
5745            let result = process
5746                .find_expect_match("hello world", "missing", false)
5747                .unwrap();
5748            assert!(result.is_none());
5749        });
5750    }
5751
5752    #[test]
5753    fn find_expect_match_regex_found() {
5754        pyo3::prepare_freethreaded_python();
5755        pyo3::Python::with_gil(|py| {
5756            let process = make_test_running_process(py);
5757            let result = process
5758                .find_expect_match("hello 123 world", r"\d+", true)
5759                .unwrap();
5760            assert!(result.is_some());
5761            let (matched, start, end, _) = result.unwrap();
5762            assert_eq!(matched, "123");
5763            assert_eq!(start, 6);
5764            assert_eq!(end, 9);
5765        });
5766    }
5767
5768    #[test]
5769    fn find_expect_match_regex_with_groups() {
5770        pyo3::prepare_freethreaded_python();
5771        pyo3::Python::with_gil(|py| {
5772            let process = make_test_running_process(py);
5773            let result = process
5774                .find_expect_match("hello 123 world", r"(\d+) (\w+)", true)
5775                .unwrap();
5776            assert!(result.is_some());
5777            let (_, _, _, groups) = result.unwrap();
5778            assert_eq!(groups.len(), 2);
5779            assert_eq!(groups[0], "123");
5780            assert_eq!(groups[1], "world");
5781        });
5782    }
5783
5784    #[test]
5785    fn find_expect_match_regex_not_found() {
5786        pyo3::prepare_freethreaded_python();
5787        pyo3::Python::with_gil(|py| {
5788            let process = make_test_running_process(py);
5789            let result = process
5790                .find_expect_match("hello world", r"\d+", true)
5791                .unwrap();
5792            assert!(result.is_none());
5793        });
5794    }
5795
5796    #[test]
5797    fn find_expect_match_invalid_regex_errors() {
5798        pyo3::prepare_freethreaded_python();
5799        pyo3::Python::with_gil(|py| {
5800            let process = make_test_running_process(py);
5801            let result = process.find_expect_match("test", r"[invalid", true);
5802            assert!(result.is_err());
5803        });
5804    }
5805
5806    fn make_test_running_process(py: Python<'_>) -> NativeRunningProcess {
5807        let cmd = pyo3::types::PyList::new(py, ["echo", "test"]).unwrap();
5808        NativeRunningProcess::new(
5809            cmd.as_any(),
5810            None,
5811            false,
5812            true,
5813            None,
5814            None,
5815            true,
5816            None,
5817            None,
5818            "inherit",
5819            "stdout",
5820            None,
5821            false,
5822        )
5823        .unwrap()
5824    }
5825
5826    // ── parse_command tests ──
5827
5828    #[test]
5829    fn parse_command_string_with_shell() {
5830        pyo3::prepare_freethreaded_python();
5831        pyo3::Python::with_gil(|py| {
5832            let cmd = pyo3::types::PyString::new(py, "echo hello");
5833            let result = parse_command(cmd.as_any(), true).unwrap();
5834            assert!(matches!(result, CommandSpec::Shell(ref s) if s == "echo hello"));
5835        });
5836    }
5837
5838    #[test]
5839    fn parse_command_string_without_shell_errors() {
5840        pyo3::prepare_freethreaded_python();
5841        pyo3::Python::with_gil(|py| {
5842            let cmd = pyo3::types::PyString::new(py, "echo hello");
5843            let result = parse_command(cmd.as_any(), false);
5844            assert!(result.is_err());
5845        });
5846    }
5847
5848    #[test]
5849    fn parse_command_list_without_shell() {
5850        pyo3::prepare_freethreaded_python();
5851        pyo3::Python::with_gil(|py| {
5852            let cmd = pyo3::types::PyList::new(py, ["echo", "hello"]).unwrap();
5853            let result = parse_command(cmd.as_any(), false).unwrap();
5854            assert!(matches!(result, CommandSpec::Argv(ref v) if v.len() == 2));
5855        });
5856    }
5857
5858    #[test]
5859    fn parse_command_list_with_shell_joins() {
5860        pyo3::prepare_freethreaded_python();
5861        pyo3::Python::with_gil(|py| {
5862            let cmd = pyo3::types::PyList::new(py, ["echo", "hello"]).unwrap();
5863            let result = parse_command(cmd.as_any(), true).unwrap();
5864            assert!(matches!(result, CommandSpec::Shell(ref s) if s == "echo hello"));
5865        });
5866    }
5867
5868    #[test]
5869    fn parse_command_empty_list_errors() {
5870        pyo3::prepare_freethreaded_python();
5871        pyo3::Python::with_gil(|py| {
5872            let cmd = pyo3::types::PyList::empty(py);
5873            let result = parse_command(cmd.as_any(), false);
5874            assert!(result.is_err());
5875        });
5876    }
5877
5878    #[test]
5879    fn parse_command_invalid_type_errors() {
5880        pyo3::prepare_freethreaded_python();
5881        pyo3::Python::with_gil(|py| {
5882            let cmd = 42i32.into_pyobject(py).unwrap();
5883            let result = parse_command(cmd.as_any(), false);
5884            assert!(result.is_err());
5885        });
5886    }
5887
5888    // ── stream_kind tests ──
5889
5890    #[test]
5891    fn stream_kind_stdout() {
5892        let result = stream_kind("stdout").unwrap();
5893        assert_eq!(result, StreamKind::Stdout);
5894    }
5895
5896    #[test]
5897    fn stream_kind_stderr() {
5898        let result = stream_kind("stderr").unwrap();
5899        assert_eq!(result, StreamKind::Stderr);
5900    }
5901
5902    #[test]
5903    fn stream_kind_invalid() {
5904        let result = stream_kind("invalid");
5905        assert!(result.is_err());
5906    }
5907
5908    // ── stdin_mode tests ──
5909
5910    #[test]
5911    fn stdin_mode_inherit() {
5912        assert_eq!(stdin_mode("inherit").unwrap(), StdinMode::Inherit);
5913    }
5914
5915    #[test]
5916    fn stdin_mode_piped() {
5917        assert_eq!(stdin_mode("piped").unwrap(), StdinMode::Piped);
5918    }
5919
5920    #[test]
5921    fn stdin_mode_null() {
5922        assert_eq!(stdin_mode("null").unwrap(), StdinMode::Null);
5923    }
5924
5925    #[test]
5926    fn stdin_mode_invalid() {
5927        assert!(stdin_mode("invalid").is_err());
5928    }
5929
5930    // ── stderr_mode tests ──
5931
5932    #[test]
5933    fn stderr_mode_stdout() {
5934        assert_eq!(stderr_mode("stdout").unwrap(), StderrMode::Stdout);
5935    }
5936
5937    #[test]
5938    fn stderr_mode_pipe() {
5939        assert_eq!(stderr_mode("pipe").unwrap(), StderrMode::Pipe);
5940    }
5941
5942    #[test]
5943    fn stderr_mode_invalid() {
5944        assert!(stderr_mode("invalid").is_err());
5945    }
5946
5947    // ── Windows-specific additional tests (iter2) ──
5948
5949    #[cfg(windows)]
5950    mod windows_additional_tests {
5951        use super::*;
5952        use winapi::um::winuser::VK_F1;
5953
5954        // ── control_character_for_unicode tests ──
5955
5956        #[test]
5957        fn control_char_at_sign() {
5958            assert_eq!(control_character_for_unicode('@' as u16), Some(0x00));
5959        }
5960
5961        #[test]
5962        fn control_char_space() {
5963            assert_eq!(control_character_for_unicode(' ' as u16), Some(0x00));
5964        }
5965
5966        #[test]
5967        fn control_char_a() {
5968            assert_eq!(control_character_for_unicode('a' as u16), Some(0x01));
5969        }
5970
5971        #[test]
5972        fn control_char_z() {
5973            assert_eq!(control_character_for_unicode('z' as u16), Some(0x1A));
5974        }
5975
5976        #[test]
5977        fn control_char_bracket() {
5978            assert_eq!(control_character_for_unicode('[' as u16), Some(0x1B));
5979        }
5980
5981        #[test]
5982        fn control_char_backslash() {
5983            assert_eq!(control_character_for_unicode('\\' as u16), Some(0x1C));
5984        }
5985
5986        #[test]
5987        fn control_char_close_bracket() {
5988            assert_eq!(control_character_for_unicode(']' as u16), Some(0x1D));
5989        }
5990
5991        #[test]
5992        fn control_char_caret() {
5993            assert_eq!(control_character_for_unicode('^' as u16), Some(0x1E));
5994        }
5995
5996        #[test]
5997        fn control_char_underscore() {
5998            assert_eq!(control_character_for_unicode('_' as u16), Some(0x1F));
5999        }
6000
6001        #[test]
6002        fn control_char_digit_returns_none() {
6003            assert_eq!(control_character_for_unicode('0' as u16), None);
6004        }
6005
6006        #[test]
6007        fn control_char_exclamation_returns_none() {
6008            assert_eq!(control_character_for_unicode('!' as u16), None);
6009        }
6010
6011        // ── terminal_input_modifier_parameter tests ──
6012
6013        #[test]
6014        fn modifier_param_no_modifiers_returns_none() {
6015            assert_eq!(terminal_input_modifier_parameter(false, false, false), None);
6016        }
6017
6018        #[test]
6019        fn modifier_param_shift_only() {
6020            assert_eq!(
6021                terminal_input_modifier_parameter(true, false, false),
6022                Some(2)
6023            );
6024        }
6025
6026        #[test]
6027        fn modifier_param_alt_only() {
6028            assert_eq!(
6029                terminal_input_modifier_parameter(false, true, false),
6030                Some(3)
6031            );
6032        }
6033
6034        #[test]
6035        fn modifier_param_ctrl_only() {
6036            assert_eq!(
6037                terminal_input_modifier_parameter(false, false, true),
6038                Some(5)
6039            );
6040        }
6041
6042        #[test]
6043        fn modifier_param_shift_ctrl() {
6044            assert_eq!(
6045                terminal_input_modifier_parameter(true, false, true),
6046                Some(6)
6047            );
6048        }
6049
6050        #[test]
6051        fn modifier_param_shift_alt() {
6052            assert_eq!(
6053                terminal_input_modifier_parameter(true, true, false),
6054                Some(4)
6055            );
6056        }
6057
6058        #[test]
6059        fn modifier_param_all_modifiers() {
6060            assert_eq!(terminal_input_modifier_parameter(true, true, true), Some(8));
6061        }
6062
6063        // ── repeated_tilde_sequence tests ──
6064
6065        #[test]
6066        fn tilde_sequence_no_modifier() {
6067            let result = repeated_tilde_sequence(3, None, 1);
6068            assert_eq!(result, b"\x1b[3~");
6069        }
6070
6071        #[test]
6072        fn tilde_sequence_with_modifier() {
6073            let result = repeated_tilde_sequence(3, Some(2), 1);
6074            assert_eq!(result, b"\x1b[3;2~");
6075        }
6076
6077        #[test]
6078        fn tilde_sequence_repeated() {
6079            let result = repeated_tilde_sequence(3, None, 3);
6080            assert_eq!(result, b"\x1b[3~\x1b[3~\x1b[3~");
6081        }
6082
6083        // ── repeated_modified_sequence tests ──
6084
6085        #[test]
6086        fn modified_sequence_no_modifier() {
6087            let result = repeated_modified_sequence(b"\x1b[A", None, 1);
6088            assert_eq!(result, b"\x1b[A");
6089        }
6090
6091        #[test]
6092        fn modified_sequence_with_modifier() {
6093            let result = repeated_modified_sequence(b"\x1b[A", Some(2), 1);
6094            assert_eq!(result, b"\x1b[1;2A");
6095        }
6096
6097        #[test]
6098        fn modified_sequence_repeated_with_modifier() {
6099            let result = repeated_modified_sequence(b"\x1b[A", Some(5), 2);
6100            assert_eq!(result, b"\x1b[1;5A\x1b[1;5A");
6101        }
6102
6103        // ── format_terminal_input_bytes tests ──
6104
6105        #[test]
6106        fn format_bytes_empty() {
6107            assert_eq!(format_terminal_input_bytes(&[]), "[]");
6108        }
6109
6110        #[test]
6111        fn format_bytes_multiple() {
6112            assert_eq!(
6113                format_terminal_input_bytes(&[0x1B, 0x5B, 0x41]),
6114                "[1b 5b 41]"
6115            );
6116        }
6117
6118        // ── native_terminal_input_trace_target tests ──
6119
6120        #[test]
6121        fn trace_target_empty_env_returns_none() {
6122            std::env::remove_var(NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV);
6123            assert!(native_terminal_input_trace_target().is_none());
6124        }
6125
6126        #[test]
6127        fn trace_target_whitespace_env_returns_none() {
6128            std::env::set_var(NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV, "   ");
6129            assert!(native_terminal_input_trace_target().is_none());
6130            std::env::remove_var(NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV);
6131        }
6132
6133        #[test]
6134        fn trace_target_valid_env_returns_value() {
6135            std::env::set_var(NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV, "/tmp/trace.log");
6136            let result = native_terminal_input_trace_target();
6137            assert_eq!(result, Some("/tmp/trace.log".to_string()));
6138            std::env::remove_var(NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV);
6139        }
6140
6141        // ── translate_console_key_event: key-up ignored ──
6142
6143        #[test]
6144        fn translate_key_up_event_returns_none() {
6145            let mut event: KEY_EVENT_RECORD = unsafe { std::mem::zeroed() };
6146            event.bKeyDown = 0;
6147            event.wVirtualKeyCode = VK_RETURN as u16;
6148            let result = translate_console_key_event(&event);
6149            assert!(result.is_none());
6150        }
6151
6152        // ── translate: F1 returns None (unknown key) ──
6153
6154        #[test]
6155        fn translate_f1_key_returns_none() {
6156            let event = key_event(VK_F1 as u16, 0, 0, 1);
6157            let result = translate_console_key_event(&event);
6158            assert!(result.is_none());
6159        }
6160
6161        // ── translate: alt prefix ──
6162
6163        #[test]
6164        fn translate_alt_a_has_escape_prefix() {
6165            let event = key_event('a' as u16, 'a' as u16, LEFT_ALT_PRESSED, 1);
6166            let result = translate_console_key_event(&event).unwrap();
6167            assert!(result.data.starts_with(b"\x1b"));
6168            assert!(result.alt);
6169        }
6170
6171        // ── translate: Ctrl+character ──
6172
6173        #[test]
6174        fn translate_ctrl_c_produces_etx() {
6175            let event = key_event('C' as u16, 'c' as u16, LEFT_CTRL_PRESSED, 1);
6176            let result = translate_console_key_event(&event).unwrap();
6177            assert_eq!(result.data, &[0x03]);
6178            assert!(result.ctrl);
6179        }
6180    }
6181
6182    // ── NativeTerminalInput tests ──
6183
6184    #[test]
6185    fn terminal_input_new_starts_closed() {
6186        let input = NativeTerminalInput::new();
6187        assert!(!input.capturing());
6188        let state = input.state.lock().unwrap();
6189        assert!(state.closed);
6190        assert!(state.events.is_empty());
6191    }
6192
6193    #[test]
6194    fn terminal_input_available_false_when_empty() {
6195        let input = NativeTerminalInput::new();
6196        assert!(!input.available());
6197    }
6198
6199    #[test]
6200    fn terminal_input_next_event_none_when_empty() {
6201        let input = NativeTerminalInput::new();
6202        assert!(input.next_event().is_none());
6203    }
6204
6205    #[test]
6206    fn terminal_input_inject_and_consume_event() {
6207        let input = NativeTerminalInput::new();
6208        {
6209            let mut state = input.state.lock().unwrap();
6210            state.events.push_back(TerminalInputEventRecord {
6211                data: b"test".to_vec(),
6212                submit: false,
6213                shift: false,
6214                ctrl: false,
6215                alt: false,
6216                virtual_key_code: 0,
6217                repeat_count: 1,
6218            });
6219        }
6220        assert!(input.available());
6221        let event = input.next_event().unwrap();
6222        assert_eq!(event.data, b"test");
6223        assert!(!input.available());
6224    }
6225
6226    #[test]
6227    #[cfg(not(windows))]
6228    fn terminal_input_start_errors_on_non_windows() {
6229        pyo3::prepare_freethreaded_python();
6230        let input = NativeTerminalInput::new();
6231        let result = input.start();
6232        assert!(result.is_err());
6233    }
6234
6235    // ── NativeTerminalInputEvent __repr__ ──
6236
6237    #[test]
6238    fn terminal_input_event_repr() {
6239        let event = NativeTerminalInputEvent {
6240            data: vec![0x0D],
6241            submit: true,
6242            shift: false,
6243            ctrl: false,
6244            alt: false,
6245            virtual_key_code: 13,
6246            repeat_count: 1,
6247        };
6248        let repr = event.__repr__();
6249        assert!(repr.contains("submit=true"));
6250        assert!(repr.contains("virtual_key_code=13"));
6251    }
6252
6253    // ── tracked_process_db_path ──
6254
6255    #[test]
6256    fn tracked_process_db_path_with_env() {
6257        pyo3::prepare_freethreaded_python();
6258        std::env::set_var("RUNNING_PROCESS_PID_DB", "/custom/path/db.sqlite3");
6259        let result = tracked_process_db_path().unwrap();
6260        assert_eq!(result, std::path::PathBuf::from("/custom/path/db.sqlite3"));
6261        std::env::remove_var("RUNNING_PROCESS_PID_DB");
6262    }
6263
6264    #[test]
6265    fn tracked_process_db_path_empty_env_falls_back() {
6266        pyo3::prepare_freethreaded_python();
6267        std::env::set_var("RUNNING_PROCESS_PID_DB", "   ");
6268        let result = tracked_process_db_path().unwrap();
6269        assert!(!result.to_str().unwrap().trim().is_empty());
6270        std::env::remove_var("RUNNING_PROCESS_PID_DB");
6271    }
6272
6273    // ── NativePtyProcess: start_terminal_input_relay on non-windows ──
6274
6275    #[test]
6276    #[cfg(not(windows))]
6277    fn pty_process_start_terminal_input_relay_errors_on_non_windows() {
6278        pyo3::prepare_freethreaded_python();
6279        pyo3::Python::with_gil(|_py| {
6280            let argv = vec!["echo".to_string(), "test".to_string()];
6281            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6282            let result = process.start_terminal_input_relay_impl();
6283            assert!(result.is_err());
6284        });
6285    }
6286
6287    #[test]
6288    #[cfg(not(windows))]
6289    fn pty_process_terminal_input_relay_active_false_on_non_windows() {
6290        pyo3::prepare_freethreaded_python();
6291        pyo3::Python::with_gil(|_py| {
6292            let argv = vec!["echo".to_string(), "test".to_string()];
6293            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6294            assert!(!process.terminal_input_relay_active());
6295        });
6296    }
6297
6298    // ── NativeProcessMetrics ──
6299
6300    #[test]
6301    fn process_metrics_sample_nonexistent_pid() {
6302        pyo3::prepare_freethreaded_python();
6303        let metrics = NativeProcessMetrics::new(999999);
6304        let (alive, cpu, io, _) = metrics.sample();
6305        assert!(!alive);
6306        assert_eq!(cpu, 0.0);
6307        assert_eq!(io, 0);
6308    }
6309
6310    #[test]
6311    fn process_metrics_prime_no_panic() {
6312        pyo3::prepare_freethreaded_python();
6313        let metrics = NativeProcessMetrics::new(999999);
6314        metrics.prime();
6315    }
6316
6317    // ── ActiveProcessRecord ──
6318
6319    #[test]
6320    fn active_process_record_clone() {
6321        let record = ActiveProcessRecord {
6322            pid: 1234,
6323            kind: "test".to_string(),
6324            command: "echo".to_string(),
6325            cwd: Some("/tmp".to_string()),
6326            started_at: 1000.0,
6327        };
6328        let cloned = record.clone();
6329        assert_eq!(cloned.pid, 1234);
6330        assert_eq!(cloned.kind, "test");
6331        assert_eq!(cloned.command, "echo");
6332        assert_eq!(cloned.cwd, Some("/tmp".to_string()));
6333    }
6334
6335    // ── NativePtyProcess: empty argv errors ──
6336
6337    #[test]
6338    fn pty_process_empty_argv_errors() {
6339        pyo3::prepare_freethreaded_python();
6340        pyo3::Python::with_gil(|_py| {
6341            let result = NativePtyProcess::new(vec![], None, None, 24, 80, None);
6342            assert!(result.is_err());
6343        });
6344    }
6345
6346    // ── NativePtyProcess: start already started errors ──
6347
6348    #[test]
6349    #[cfg(not(windows))]
6350    fn pty_process_start_already_started_errors() {
6351        pyo3::prepare_freethreaded_python();
6352        pyo3::Python::with_gil(|_py| {
6353            let argv = vec![
6354                "python".to_string(),
6355                "-c".to_string(),
6356                "import time; time.sleep(0.1)".to_string(),
6357            ];
6358            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6359            process.start_impl().unwrap();
6360            let result = process.start_impl();
6361            assert!(result.is_err());
6362            let _ = process.close_impl();
6363        });
6364    }
6365
6366    // ── Iteration 3: NativePtyBuffer additional tests ──
6367
6368    #[test]
6369    fn pty_buffer_new_defaults() {
6370        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
6371        assert!(!buf.available());
6372        assert_eq!(buf.history_bytes(), 0);
6373    }
6374
6375    #[test]
6376    fn pty_buffer_record_output_makes_available() {
6377        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
6378        buf.record_output(b"hello");
6379        assert!(buf.available());
6380    }
6381
6382    #[test]
6383    fn pty_buffer_history_bytes_accumulates() {
6384        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
6385        buf.record_output(b"hello");
6386        assert_eq!(buf.history_bytes(), 5);
6387        buf.record_output(b" world");
6388        assert_eq!(buf.history_bytes(), 11);
6389    }
6390
6391    #[test]
6392    fn pty_buffer_clear_history_resets_to_zero() {
6393        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
6394        buf.record_output(b"data");
6395        let released = buf.clear_history();
6396        assert_eq!(released, 4);
6397        assert_eq!(buf.history_bytes(), 0);
6398    }
6399
6400    #[test]
6401    fn pty_buffer_close_sets_closed_flag() {
6402        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
6403        buf.close();
6404        let state = buf.state.lock().unwrap();
6405        assert!(state.closed);
6406    }
6407
6408    #[test]
6409    fn pty_buffer_record_multiple_chunks_all_available() {
6410        let buf = NativePtyBuffer::new(false, "utf-8", "replace");
6411        buf.record_output(b"a");
6412        buf.record_output(b"bb");
6413        buf.record_output(b"ccc");
6414        assert_eq!(buf.history_bytes(), 6);
6415        let state = buf.state.lock().unwrap();
6416        assert_eq!(state.chunks.len(), 3);
6417    }
6418
6419    // ── Iteration 3: PTY Process Integration Tests ──
6420
6421    #[test]
6422    fn pty_process_pid_none_before_start() {
6423        pyo3::prepare_freethreaded_python();
6424        pyo3::Python::with_gil(|_py| {
6425            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6426            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6427            assert!(process.pid().unwrap().is_none());
6428        });
6429    }
6430
6431    #[test]
6432    #[cfg(not(windows))]
6433    fn pty_process_lifecycle_start_wait_close() {
6434        pyo3::prepare_freethreaded_python();
6435        pyo3::Python::with_gil(|_py| {
6436            let argv = vec![
6437                "python".to_string(),
6438                "-c".to_string(),
6439                "print('hello')".to_string(),
6440            ];
6441            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6442            process.start_impl().unwrap();
6443            assert!(process.pid().unwrap().is_some());
6444            let code = process.wait_impl(Some(10.0)).unwrap();
6445            assert_eq!(code, 0);
6446            let _ = process.close_impl();
6447        });
6448    }
6449
6450    #[test]
6451    #[cfg(not(windows))]
6452    fn pty_process_poll_none_while_running() {
6453        pyo3::prepare_freethreaded_python();
6454        pyo3::Python::with_gil(|_py| {
6455            let argv = vec![
6456                "python".to_string(),
6457                "-c".to_string(),
6458                "import time; time.sleep(5)".to_string(),
6459            ];
6460            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6461            process.start_impl().unwrap();
6462            assert!(process.poll().unwrap().is_none());
6463            let _ = process.close_impl();
6464        });
6465    }
6466
6467    #[test]
6468    #[cfg(not(windows))]
6469    fn pty_process_nonzero_exit_code() {
6470        pyo3::prepare_freethreaded_python();
6471        pyo3::Python::with_gil(|_py| {
6472            let argv = vec![
6473                "python".to_string(),
6474                "-c".to_string(),
6475                "import sys; sys.exit(42)".to_string(),
6476            ];
6477            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6478            process.start_impl().unwrap();
6479            let code = process.wait_impl(Some(10.0)).unwrap();
6480            assert_eq!(code, 42);
6481            let _ = process.close_impl();
6482        });
6483    }
6484
6485    #[test]
6486    fn pty_process_write_before_start_errors() {
6487        pyo3::prepare_freethreaded_python();
6488        pyo3::Python::with_gil(|_py| {
6489            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6490            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6491            assert!(process.write_impl(b"test", false).is_err());
6492        });
6493    }
6494
6495    #[test]
6496    #[cfg(not(windows))]
6497    fn pty_process_input_metrics_tracked() {
6498        pyo3::prepare_freethreaded_python();
6499        pyo3::Python::with_gil(|_py| {
6500            let argv = vec![
6501                "python".to_string(),
6502                "-c".to_string(),
6503                "import time; time.sleep(2)".to_string(),
6504            ];
6505            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6506            process.start_impl().unwrap();
6507            assert_eq!(process.pty_input_bytes_total(), 0);
6508            let _ = process.write_impl(b"hello\n", false);
6509            assert_eq!(process.pty_input_bytes_total(), 6);
6510            assert_eq!(process.pty_newline_events_total(), 1);
6511            let _ = process.write_impl(b"x", true);
6512            assert_eq!(process.pty_submit_events_total(), 1);
6513            let _ = process.close_impl();
6514        });
6515    }
6516
6517    #[test]
6518    #[cfg(not(windows))]
6519    fn pty_process_resize_while_running() {
6520        pyo3::prepare_freethreaded_python();
6521        pyo3::Python::with_gil(|_py| {
6522            let argv = vec![
6523                "python".to_string(),
6524                "-c".to_string(),
6525                "import time; time.sleep(2)".to_string(),
6526            ];
6527            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6528            process.start_impl().unwrap();
6529            assert!(process.resize_impl(40, 120).is_ok());
6530            let _ = process.close_impl();
6531        });
6532    }
6533
6534    #[test]
6535    #[cfg(not(windows))]
6536    fn pty_process_kill_running_process() {
6537        pyo3::prepare_freethreaded_python();
6538        pyo3::Python::with_gil(|_py| {
6539            let argv = vec![
6540                "python".to_string(),
6541                "-c".to_string(),
6542                "import time; time.sleep(0.1)".to_string(),
6543            ];
6544            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6545            process.start_impl().unwrap();
6546            assert!(process.kill_impl().is_ok());
6547        });
6548    }
6549
6550    #[test]
6551    #[cfg(not(windows))]
6552    fn pty_process_terminate_running_process() {
6553        pyo3::prepare_freethreaded_python();
6554        pyo3::Python::with_gil(|_py| {
6555            let argv = vec![
6556                "python".to_string(),
6557                "-c".to_string(),
6558                "import time; time.sleep(0.1)".to_string(),
6559            ];
6560            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6561            process.start_impl().unwrap();
6562            assert!(process.terminate_impl().is_ok());
6563            let _ = process.close_impl();
6564        });
6565    }
6566
6567    #[test]
6568    #[cfg(not(windows))]
6569    fn pty_process_close_already_closed_is_noop() {
6570        pyo3::prepare_freethreaded_python();
6571        pyo3::Python::with_gil(|_py| {
6572            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6573            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6574            process.start_impl().unwrap();
6575            let _ = process.wait_impl(Some(10.0));
6576            let _ = process.close_impl();
6577            assert!(process.close_impl().is_ok());
6578        });
6579    }
6580
6581    #[test]
6582    #[cfg(not(windows))]
6583    fn pty_process_wait_timeout_errors() {
6584        pyo3::prepare_freethreaded_python();
6585        pyo3::Python::with_gil(|_py| {
6586            let argv = vec![
6587                "python".to_string(),
6588                "-c".to_string(),
6589                "import time; time.sleep(0.1)".to_string(),
6590            ];
6591            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6592            process.start_impl().unwrap();
6593            assert!(process.wait_impl(Some(0.1)).is_err());
6594            let _ = process.close_impl();
6595        });
6596    }
6597
6598    #[test]
6599    fn pty_process_send_interrupt_before_start_errors() {
6600        pyo3::prepare_freethreaded_python();
6601        pyo3::Python::with_gil(|_py| {
6602            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6603            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6604            assert!(process.send_interrupt_impl().is_err());
6605        });
6606    }
6607
6608    #[test]
6609    fn pty_process_terminate_before_start_errors() {
6610        pyo3::prepare_freethreaded_python();
6611        pyo3::Python::with_gil(|_py| {
6612            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6613            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6614            assert!(process.terminate_impl().is_err());
6615        });
6616    }
6617
6618    #[test]
6619    fn pty_process_kill_before_start_errors() {
6620        pyo3::prepare_freethreaded_python();
6621        pyo3::Python::with_gil(|_py| {
6622            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6623            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6624            assert!(process.kill_impl().is_err());
6625        });
6626    }
6627
6628    // ── Iteration 3: Utility function tests ──
6629
6630    #[test]
6631    fn kill_process_tree_nonexistent_pid_is_noop() {
6632        kill_process_tree_impl(999999, 0.5);
6633    }
6634
6635    #[test]
6636    fn get_process_tree_info_current_pid() {
6637        let pid = std::process::id();
6638        let info = native_get_process_tree_info(pid);
6639        assert!(info.contains(&format!("{}", pid)));
6640    }
6641
6642    #[test]
6643    fn get_process_tree_info_nonexistent_pid() {
6644        let info = native_get_process_tree_info(999999);
6645        assert!(info.contains("Could not get process info"));
6646    }
6647
6648    #[test]
6649    fn register_and_list_active_processes() {
6650        let fake_pid = 777777u32;
6651        register_active_process(
6652            fake_pid,
6653            "test",
6654            "echo hello",
6655            Some("/tmp".to_string()),
6656            1000.0,
6657        );
6658        let items = native_list_active_processes();
6659        assert!(items.iter().any(|e| e.0 == fake_pid));
6660        unregister_active_process(fake_pid);
6661        let items = native_list_active_processes();
6662        assert!(!items.iter().any(|e| e.0 == fake_pid));
6663    }
6664
6665    #[test]
6666    fn process_created_at_current_process_returns_some() {
6667        let created = process_created_at(std::process::id());
6668        assert!(created.is_some());
6669        assert!(created.unwrap() > 0.0);
6670    }
6671
6672    #[test]
6673    fn process_created_at_nonexistent_returns_none() {
6674        assert!(process_created_at(999999).is_none());
6675    }
6676
6677    #[test]
6678    fn same_process_identity_current_process_matches() {
6679        let pid = std::process::id();
6680        let created = process_created_at(pid).unwrap();
6681        assert!(same_process_identity(pid, created, 2.0));
6682    }
6683
6684    #[test]
6685    fn same_process_identity_wrong_time_no_match() {
6686        assert!(!same_process_identity(std::process::id(), 0.0, 1.0));
6687    }
6688
6689    #[test]
6690    #[cfg(windows)]
6691    fn windows_apply_process_priority_current_pid_ok() {
6692        pyo3::prepare_freethreaded_python();
6693        assert!(windows_apply_process_priority_impl(std::process::id(), 0).is_ok());
6694    }
6695
6696    #[test]
6697    #[cfg(windows)]
6698    fn windows_apply_process_priority_nonexistent_errors() {
6699        pyo3::prepare_freethreaded_python();
6700        assert!(windows_apply_process_priority_impl(999999, 0).is_err());
6701    }
6702
6703    #[test]
6704    fn signal_bool_new_default_false() {
6705        assert!(!NativeSignalBool::new(false).load_nolock());
6706    }
6707
6708    #[test]
6709    fn signal_bool_new_true() {
6710        assert!(NativeSignalBool::new(true).load_nolock());
6711    }
6712
6713    #[test]
6714    fn signal_bool_store_locked_changes_value() {
6715        let sb = NativeSignalBool::new(false);
6716        sb.store_locked(true);
6717        assert!(sb.load_nolock());
6718    }
6719
6720    #[test]
6721    fn signal_bool_compare_and_swap_success_iter3() {
6722        let sb = NativeSignalBool::new(false);
6723        assert!(sb.compare_and_swap_locked(false, true));
6724        assert!(sb.load_nolock());
6725    }
6726
6727    #[test]
6728    fn idle_monitor_state_initial_values() {
6729        let state = IdleMonitorState {
6730            last_reset_at: Instant::now(),
6731            returncode: None,
6732            interrupted: false,
6733        };
6734        assert!(state.returncode.is_none());
6735        assert!(!state.interrupted);
6736    }
6737
6738    #[test]
6739    #[cfg(windows)]
6740    fn terminal_input_wait_returns_event_immediately() {
6741        let state = Arc::new(Mutex::new(TerminalInputState {
6742            events: {
6743                let mut q = VecDeque::new();
6744                q.push_back(TerminalInputEventRecord {
6745                    data: b"x".to_vec(),
6746                    submit: false,
6747                    shift: false,
6748                    ctrl: false,
6749                    alt: false,
6750                    virtual_key_code: 0,
6751                    repeat_count: 1,
6752                });
6753                q
6754            },
6755            closed: false,
6756        }));
6757        let condvar = Arc::new(Condvar::new());
6758        match wait_for_terminal_input_event(&state, &condvar, Some(Duration::from_millis(100))) {
6759            TerminalInputWaitOutcome::Event(e) => assert_eq!(e.data, b"x"),
6760            _ => panic!("expected Event"),
6761        }
6762    }
6763
6764    #[test]
6765    #[cfg(windows)]
6766    fn terminal_input_wait_returns_closed() {
6767        let state = Arc::new(Mutex::new(TerminalInputState {
6768            events: VecDeque::new(),
6769            closed: true,
6770        }));
6771        let condvar = Arc::new(Condvar::new());
6772        assert!(matches!(
6773            wait_for_terminal_input_event(&state, &condvar, Some(Duration::from_millis(100))),
6774            TerminalInputWaitOutcome::Closed
6775        ));
6776    }
6777
6778    #[test]
6779    #[cfg(windows)]
6780    fn terminal_input_wait_returns_timeout() {
6781        let state = Arc::new(Mutex::new(TerminalInputState {
6782            events: VecDeque::new(),
6783            closed: false,
6784        }));
6785        let condvar = Arc::new(Condvar::new());
6786        assert!(matches!(
6787            wait_for_terminal_input_event(&state, &condvar, Some(Duration::from_millis(50))),
6788            TerminalInputWaitOutcome::Timeout
6789        ));
6790    }
6791
6792    #[test]
6793    fn native_running_process_is_pty_available_false() {
6794        assert!(!NativeRunningProcess::is_pty_available());
6795    }
6796
6797    #[test]
6798    #[cfg(not(windows))]
6799    fn posix_input_payload_passthrough() {
6800        assert_eq!(pty_platform::input_payload(b"hello\n"), b"hello\n");
6801    }
6802
6803    // ══════════════════════════════════════════════════════════════
6804    // Iteration 4: Windows PTY process lifecycle + NativeRunningProcess
6805    // ══════════════════════════════════════════════════════════════
6806
6807    // ── Windows PTY process lifecycle tests ──
6808    //
6809    // Note: On Windows ConPTY, the child process cannot exit cleanly until
6810    // the master pipe is dropped. Therefore `wait_impl()` alone may block
6811    // indefinitely — use `close_impl()` (which drops handles then waits)
6812    // for lifecycle cleanup. Tests that need the exit code must use
6813    // `kill_impl()` which explicitly drops handles.
6814
6815    #[test]
6816    #[cfg(windows)]
6817    fn pty_process_start_and_close_windows() {
6818        pyo3::prepare_freethreaded_python();
6819        pyo3::Python::with_gil(|_py| {
6820            let argv = vec![
6821                "python".to_string(),
6822                "-c".to_string(),
6823                "print('hello')".to_string(),
6824            ];
6825            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6826            process.start_impl().unwrap();
6827            assert!(process.pid().unwrap().is_some());
6828            // close drops handles then waits — this is the correct Windows lifecycle
6829            assert!(process.close_impl().is_ok());
6830        });
6831    }
6832
6833    #[test]
6834    #[cfg(windows)]
6835    fn pty_process_poll_none_while_running_windows() {
6836        pyo3::prepare_freethreaded_python();
6837        pyo3::Python::with_gil(|_py| {
6838            let argv = vec![
6839                "python".to_string(),
6840                "-c".to_string(),
6841                "import time; time.sleep(5)".to_string(),
6842            ];
6843            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6844            process.start_impl().unwrap();
6845            assert!(process.poll().unwrap().is_none());
6846            let _ = process.close_impl();
6847        });
6848    }
6849
6850    #[test]
6851    #[cfg(windows)]
6852    fn pty_process_kill_running_process_windows() {
6853        pyo3::prepare_freethreaded_python();
6854        pyo3::Python::with_gil(|_py| {
6855            let argv = vec![
6856                "python".to_string(),
6857                "-c".to_string(),
6858                "import time; time.sleep(0.1)".to_string(),
6859            ];
6860            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6861            process.start_impl().unwrap();
6862            assert!(process.kill_impl().is_ok());
6863        });
6864    }
6865
6866    #[test]
6867    #[cfg(windows)]
6868    fn pty_process_terminate_running_process_windows() {
6869        pyo3::prepare_freethreaded_python();
6870        pyo3::Python::with_gil(|_py| {
6871            let argv = vec![
6872                "python".to_string(),
6873                "-c".to_string(),
6874                "import time; time.sleep(0.1)".to_string(),
6875            ];
6876            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6877            process.start_impl().unwrap();
6878            // On Windows, terminate delegates to kill
6879            assert!(process.terminate_impl().is_ok());
6880        });
6881    }
6882
6883    #[test]
6884    #[cfg(windows)]
6885    fn pty_process_close_not_started_is_ok_windows() {
6886        pyo3::prepare_freethreaded_python();
6887        pyo3::Python::with_gil(|_py| {
6888            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6889            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6890            // close before start should be ok (handles are None)
6891            assert!(process.close_impl().is_ok());
6892        });
6893    }
6894
6895    #[test]
6896    #[cfg(windows)]
6897    fn pty_process_start_already_started_errors_windows() {
6898        pyo3::prepare_freethreaded_python();
6899        pyo3::Python::with_gil(|_py| {
6900            let argv = vec![
6901                "python".to_string(),
6902                "-c".to_string(),
6903                "import time; time.sleep(0.1)".to_string(),
6904            ];
6905            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6906            process.start_impl().unwrap();
6907            let result = process.start_impl();
6908            assert!(result.is_err());
6909            let _ = process.close_impl();
6910        });
6911    }
6912
6913    #[test]
6914    #[cfg(windows)]
6915    fn pty_process_resize_while_running_windows() {
6916        pyo3::prepare_freethreaded_python();
6917        pyo3::Python::with_gil(|_py| {
6918            let argv = vec![
6919                "python".to_string(),
6920                "-c".to_string(),
6921                "import time; time.sleep(2)".to_string(),
6922            ];
6923            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6924            process.start_impl().unwrap();
6925            assert!(process.resize_impl(40, 120).is_ok());
6926            let _ = process.close_impl();
6927        });
6928    }
6929
6930    #[test]
6931    #[cfg(windows)]
6932    fn pty_process_write_windows() {
6933        pyo3::prepare_freethreaded_python();
6934        pyo3::Python::with_gil(|_py| {
6935            let argv = vec![
6936                "python".to_string(),
6937                "-c".to_string(),
6938                "import time; time.sleep(2)".to_string(),
6939            ];
6940            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6941            process.start_impl().unwrap();
6942            let _ = process.write_impl(b"hello\n", false);
6943            assert!(process.pty_input_bytes_total() >= 6);
6944            assert!(process.pty_newline_events_total() >= 1);
6945            let _ = process.close_impl();
6946        });
6947    }
6948
6949    #[test]
6950    #[cfg(windows)]
6951    fn pty_process_input_metrics_tracked_windows() {
6952        pyo3::prepare_freethreaded_python();
6953        pyo3::Python::with_gil(|_py| {
6954            let argv = vec![
6955                "python".to_string(),
6956                "-c".to_string(),
6957                "import time; time.sleep(2)".to_string(),
6958            ];
6959            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6960            process.start_impl().unwrap();
6961            assert_eq!(process.pty_input_bytes_total(), 0);
6962            let _ = process.write_impl(b"hello\n", false);
6963            assert_eq!(process.pty_input_bytes_total(), 6);
6964            assert_eq!(process.pty_newline_events_total(), 1);
6965            let _ = process.write_impl(b"x", true);
6966            assert_eq!(process.pty_submit_events_total(), 1);
6967            let _ = process.close_impl();
6968        });
6969    }
6970
6971    #[test]
6972    #[cfg(windows)]
6973    fn pty_process_send_interrupt_windows() {
6974        pyo3::prepare_freethreaded_python();
6975        pyo3::Python::with_gil(|_py| {
6976            let argv = vec![
6977                "python".to_string(),
6978                "-c".to_string(),
6979                "import time; time.sleep(0.1)".to_string(),
6980            ];
6981            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
6982            process.start_impl().unwrap();
6983            // send_interrupt on Windows writes Ctrl+C byte via PTY
6984            assert!(process.send_interrupt_impl().is_ok());
6985            let _ = process.close_impl();
6986        });
6987    }
6988
6989    #[test]
6990    #[cfg(windows)]
6991    fn pty_process_with_cwd_windows() {
6992        pyo3::prepare_freethreaded_python();
6993        pyo3::Python::with_gil(|_py| {
6994            let tmp = std::env::temp_dir();
6995            let cwd = tmp.to_str().unwrap().to_string();
6996            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
6997            let process = NativePtyProcess::new(argv, Some(cwd), None, 24, 80, None).unwrap();
6998            process.start_impl().unwrap();
6999            assert!(process.close_impl().is_ok());
7000        });
7001    }
7002
7003    #[test]
7004    #[cfg(windows)]
7005    fn pty_process_with_env_windows() {
7006        pyo3::prepare_freethreaded_python();
7007        pyo3::Python::with_gil(|py| {
7008            let env = pyo3::types::PyDict::new(py);
7009            if let Ok(path) = std::env::var("PATH") {
7010                env.set_item("PATH", &path).unwrap();
7011            }
7012            if let Ok(root) = std::env::var("SystemRoot") {
7013                env.set_item("SystemRoot", &root).unwrap();
7014            }
7015            env.set_item("RP_TEST_PTY", "test_value").unwrap();
7016            let argv = vec![
7017                "python".to_string(),
7018                "-c".to_string(),
7019                "import os; print(os.environ.get('RP_TEST_PTY', 'MISSING'))".to_string(),
7020            ];
7021            let process = NativePtyProcess::new(argv, None, Some(env), 24, 80, None).unwrap();
7022            process.start_impl().unwrap();
7023            assert!(process.close_impl().is_ok());
7024        });
7025    }
7026
7027    // ── Windows PTY terminal input relay tests ──
7028
7029    #[test]
7030    #[cfg(windows)]
7031    fn pty_process_terminal_input_relay_not_active_initially() {
7032        pyo3::prepare_freethreaded_python();
7033        pyo3::Python::with_gil(|_py| {
7034            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
7035            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
7036            assert!(!process.terminal_input_relay_active());
7037        });
7038    }
7039
7040    #[test]
7041    #[cfg(windows)]
7042    fn pty_process_stop_terminal_input_relay_noop_when_not_started() {
7043        pyo3::prepare_freethreaded_python();
7044        pyo3::Python::with_gil(|_py| {
7045            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
7046            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
7047            process.stop_terminal_input_relay_impl(); // should not panic
7048        });
7049    }
7050
7051    // ── Windows-specific helper function tests ──
7052
7053    #[test]
7054    #[cfg(windows)]
7055    fn assign_child_to_job_null_handle_errors() {
7056        pyo3::prepare_freethreaded_python();
7057        let result = assign_child_to_windows_kill_on_close_job(None);
7058        assert!(result.is_err());
7059    }
7060
7061    #[test]
7062    #[cfg(windows)]
7063    fn apply_windows_pty_priority_none_handle_ok() {
7064        pyo3::prepare_freethreaded_python();
7065        // None handle with any nice value should be Ok (early return)
7066        assert!(apply_windows_pty_priority(None, Some(5)).is_ok());
7067        assert!(apply_windows_pty_priority(None, None).is_ok());
7068    }
7069
7070    #[test]
7071    #[cfg(windows)]
7072    fn apply_windows_pty_priority_zero_nice_noop() {
7073        pyo3::prepare_freethreaded_python();
7074        // Some handle with nice=0 → flags=0 → early return Ok
7075        use std::os::windows::io::AsRawHandle;
7076        let current = std::process::Command::new("cmd")
7077            .args(["/C", "echo"])
7078            .stdout(std::process::Stdio::null())
7079            .spawn()
7080            .unwrap();
7081        let handle = current.as_raw_handle();
7082        assert!(apply_windows_pty_priority(Some(handle), Some(0)).is_ok());
7083        assert!(apply_windows_pty_priority(Some(handle), None).is_ok());
7084    }
7085
7086    // ── NativeRunningProcess lifecycle tests ──
7087
7088    #[test]
7089    fn running_process_start_wait_lifecycle() {
7090        pyo3::prepare_freethreaded_python();
7091        pyo3::Python::with_gil(|py| {
7092            let cmd = pyo3::types::PyList::new(py, ["python", "-c", "print('hello')"]).unwrap();
7093            let process = NativeRunningProcess::new(
7094                cmd.as_any(),
7095                None,
7096                false,
7097                true,
7098                None,
7099                None,
7100                true,
7101                None,
7102                None,
7103                "inherit",
7104                "stdout",
7105                None,
7106                false,
7107            )
7108            .unwrap();
7109            process.start_impl().unwrap();
7110            assert!(process.inner.pid().is_some());
7111            let code = process.wait_impl(py, Some(10.0)).unwrap();
7112            assert_eq!(code, 0);
7113        });
7114    }
7115
7116    #[test]
7117    fn running_process_kill_running() {
7118        pyo3::prepare_freethreaded_python();
7119        pyo3::Python::with_gil(|py| {
7120            let cmd =
7121                pyo3::types::PyList::new(py, ["python", "-c", "import time; time.sleep(0.1)"])
7122                    .unwrap();
7123            let process = NativeRunningProcess::new(
7124                cmd.as_any(),
7125                None,
7126                false,
7127                false,
7128                None,
7129                None,
7130                true,
7131                None,
7132                None,
7133                "inherit",
7134                "stdout",
7135                None,
7136                false,
7137            )
7138            .unwrap();
7139            process.start_impl().unwrap();
7140            assert!(process.kill_impl().is_ok());
7141        });
7142    }
7143
7144    #[test]
7145    fn running_process_terminate_running() {
7146        pyo3::prepare_freethreaded_python();
7147        pyo3::Python::with_gil(|py| {
7148            let cmd =
7149                pyo3::types::PyList::new(py, ["python", "-c", "import time; time.sleep(0.1)"])
7150                    .unwrap();
7151            let process = NativeRunningProcess::new(
7152                cmd.as_any(),
7153                None,
7154                false,
7155                false,
7156                None,
7157                None,
7158                true,
7159                None,
7160                None,
7161                "inherit",
7162                "stdout",
7163                None,
7164                false,
7165            )
7166            .unwrap();
7167            process.start_impl().unwrap();
7168            assert!(process.terminate_impl().is_ok());
7169        });
7170    }
7171
7172    #[test]
7173    fn running_process_close_finished() {
7174        pyo3::prepare_freethreaded_python();
7175        pyo3::Python::with_gil(|py| {
7176            let cmd = pyo3::types::PyList::new(py, ["python", "-c", "pass"]).unwrap();
7177            let process = NativeRunningProcess::new(
7178                cmd.as_any(),
7179                None,
7180                false,
7181                false,
7182                None,
7183                None,
7184                true,
7185                None,
7186                None,
7187                "inherit",
7188                "stdout",
7189                None,
7190                false,
7191            )
7192            .unwrap();
7193            process.start_impl().unwrap();
7194            let _ = process.wait_impl(py, Some(10.0));
7195            assert!(process.close_impl(py).is_ok());
7196        });
7197    }
7198
7199    #[test]
7200    fn running_process_close_running() {
7201        pyo3::prepare_freethreaded_python();
7202        pyo3::Python::with_gil(|py| {
7203            let cmd =
7204                pyo3::types::PyList::new(py, ["python", "-c", "import time; time.sleep(0.1)"])
7205                    .unwrap();
7206            let process = NativeRunningProcess::new(
7207                cmd.as_any(),
7208                None,
7209                false,
7210                false,
7211                None,
7212                None,
7213                true,
7214                None,
7215                None,
7216                "inherit",
7217                "stdout",
7218                None,
7219                false,
7220            )
7221            .unwrap();
7222            process.start_impl().unwrap();
7223            assert!(process.close_impl(py).is_ok());
7224        });
7225    }
7226
7227    // ── NativeRunningProcess decode/text mode tests ──
7228
7229    #[test]
7230    fn running_process_decode_line_text_mode() {
7231        pyo3::prepare_freethreaded_python();
7232        pyo3::Python::with_gil(|py| {
7233            let cmd = pyo3::types::PyList::new(py, ["echo", "test"]).unwrap();
7234            let process = NativeRunningProcess::new(
7235                cmd.as_any(),
7236                None,
7237                false,
7238                true,
7239                None,
7240                None,
7241                true, // text=true
7242                None,
7243                None,
7244                "inherit",
7245                "stdout",
7246                None,
7247                false,
7248            )
7249            .unwrap();
7250            let result = process.decode_line_to_string(py, b"hello world").unwrap();
7251            assert_eq!(result, "hello world");
7252        });
7253    }
7254
7255    #[test]
7256    fn running_process_decode_line_binary_mode() {
7257        pyo3::prepare_freethreaded_python();
7258        pyo3::Python::with_gil(|py| {
7259            let cmd = pyo3::types::PyList::new(py, ["echo", "test"]).unwrap();
7260            let process = NativeRunningProcess::new(
7261                cmd.as_any(),
7262                None,
7263                false,
7264                true,
7265                None,
7266                None,
7267                false, // text=false
7268                None,
7269                None,
7270                "inherit",
7271                "stdout",
7272                None,
7273                false,
7274            )
7275            .unwrap();
7276            let result = process.decode_line_to_string(py, b"\xff\xfe").unwrap();
7277            // Binary mode uses lossy conversion
7278            assert!(!result.is_empty());
7279        });
7280    }
7281
7282    #[test]
7283    fn running_process_decode_line_custom_encoding() {
7284        pyo3::prepare_freethreaded_python();
7285        pyo3::Python::with_gil(|py| {
7286            let cmd = pyo3::types::PyList::new(py, ["echo", "test"]).unwrap();
7287            let process = NativeRunningProcess::new(
7288                cmd.as_any(),
7289                None,
7290                false,
7291                true,
7292                None,
7293                None,
7294                true,
7295                Some("ascii".to_string()),
7296                Some("replace".to_string()),
7297                "inherit",
7298                "stdout",
7299                None,
7300                false,
7301            )
7302            .unwrap();
7303            let result = process.decode_line_to_string(py, b"hello").unwrap();
7304            assert_eq!(result, "hello");
7305        });
7306    }
7307
7308    #[test]
7309    fn running_process_captured_stream_text() {
7310        pyo3::prepare_freethreaded_python();
7311        pyo3::Python::with_gil(|py| {
7312            let cmd =
7313                pyo3::types::PyList::new(py, ["python", "-c", "print('line1'); print('line2')"])
7314                    .unwrap();
7315            let process = NativeRunningProcess::new(
7316                cmd.as_any(),
7317                None,
7318                false,
7319                true,
7320                None,
7321                None,
7322                true,
7323                None,
7324                None,
7325                "inherit",
7326                "stdout",
7327                None,
7328                false,
7329            )
7330            .unwrap();
7331            process.start_impl().unwrap();
7332            let _ = process.wait_impl(py, Some(10.0));
7333            let text = process
7334                .captured_stream_text(py, StreamKind::Stdout)
7335                .unwrap();
7336            assert!(text.contains("line1"));
7337            assert!(text.contains("line2"));
7338        });
7339    }
7340
7341    #[test]
7342    fn running_process_captured_combined_text() {
7343        pyo3::prepare_freethreaded_python();
7344        pyo3::Python::with_gil(|py| {
7345            let cmd = pyo3::types::PyList::new(
7346                py,
7347                [
7348                    "python",
7349                    "-c",
7350                    "import sys; print('out'); print('err', file=sys.stderr)",
7351                ],
7352            )
7353            .unwrap();
7354            let process = NativeRunningProcess::new(
7355                cmd.as_any(),
7356                None,
7357                false,
7358                true,
7359                None,
7360                None,
7361                true,
7362                None,
7363                None,
7364                "inherit",
7365                "pipe",
7366                None,
7367                false,
7368            )
7369            .unwrap();
7370            process.start_impl().unwrap();
7371            let _ = process.wait_impl(py, Some(10.0));
7372            let text = process.captured_combined_text(py).unwrap();
7373            assert!(text.contains("out"));
7374            assert!(text.contains("err"));
7375        });
7376    }
7377
7378    #[test]
7379    fn running_process_read_status_text_stream() {
7380        pyo3::prepare_freethreaded_python();
7381        pyo3::Python::with_gil(|py| {
7382            let cmd = pyo3::types::PyList::new(py, ["python", "-c", "print('data')"]).unwrap();
7383            let process = NativeRunningProcess::new(
7384                cmd.as_any(),
7385                None,
7386                false,
7387                true,
7388                None,
7389                None,
7390                true,
7391                None,
7392                None,
7393                "inherit",
7394                "stdout",
7395                None,
7396                false,
7397            )
7398            .unwrap();
7399            process.start_impl().unwrap();
7400            let _ = process.wait_impl(py, Some(10.0));
7401            std::thread::sleep(Duration::from_millis(50));
7402            // Read from stdout
7403            let status = process
7404                .read_status_text(Some(StreamKind::Stdout), Some(Duration::from_millis(100)));
7405            assert!(status.is_ok());
7406        });
7407    }
7408
7409    #[test]
7410    fn running_process_read_status_text_combined() {
7411        pyo3::prepare_freethreaded_python();
7412        pyo3::Python::with_gil(|py| {
7413            let cmd = pyo3::types::PyList::new(py, ["python", "-c", "print('data')"]).unwrap();
7414            let process = NativeRunningProcess::new(
7415                cmd.as_any(),
7416                None,
7417                false,
7418                true,
7419                None,
7420                None,
7421                true,
7422                None,
7423                None,
7424                "inherit",
7425                "stdout",
7426                None,
7427                false,
7428            )
7429            .unwrap();
7430            process.start_impl().unwrap();
7431            let _ = process.wait_impl(py, Some(10.0));
7432            std::thread::sleep(Duration::from_millis(50));
7433            // Read from combined (None stream)
7434            let status = process.read_status_text(None, Some(Duration::from_millis(100)));
7435            assert!(status.is_ok());
7436        });
7437    }
7438
7439    #[test]
7440    fn running_process_decode_line_returns_bytes_in_binary_mode() {
7441        pyo3::prepare_freethreaded_python();
7442        pyo3::Python::with_gil(|py| {
7443            let cmd = pyo3::types::PyList::new(py, ["echo", "test"]).unwrap();
7444            let process = NativeRunningProcess::new(
7445                cmd.as_any(),
7446                None,
7447                false,
7448                true,
7449                None,
7450                None,
7451                false, // text=false → bytes mode
7452                None,
7453                None,
7454                "inherit",
7455                "stdout",
7456                None,
7457                false,
7458            )
7459            .unwrap();
7460            let result = process.decode_line(py, b"hello").unwrap();
7461            // In binary mode, should return PyBytes
7462            let bytes: Vec<u8> = result.extract(py).unwrap();
7463            assert_eq!(bytes, b"hello");
7464        });
7465    }
7466
7467    #[test]
7468    fn running_process_decode_line_returns_string_in_text_mode() {
7469        pyo3::prepare_freethreaded_python();
7470        pyo3::Python::with_gil(|py| {
7471            let cmd = pyo3::types::PyList::new(py, ["echo", "test"]).unwrap();
7472            let process = NativeRunningProcess::new(
7473                cmd.as_any(),
7474                None,
7475                false,
7476                true,
7477                None,
7478                None,
7479                true, // text=true → string mode
7480                None,
7481                None,
7482                "inherit",
7483                "stdout",
7484                None,
7485                false,
7486            )
7487            .unwrap();
7488            let result = process.decode_line(py, b"hello").unwrap();
7489            let text: String = result.extract(py).unwrap();
7490            assert_eq!(text, "hello");
7491        });
7492    }
7493
7494    // ── NativePtyBuffer decode_chunk tests ──
7495
7496    #[test]
7497    fn pty_buffer_decode_chunk_text_mode() {
7498        pyo3::prepare_freethreaded_python();
7499        pyo3::Python::with_gil(|py| {
7500            let buf = NativePtyBuffer::new(true, "utf-8", "replace");
7501            let result = buf.decode_chunk(py, b"hello").unwrap();
7502            let text: String = result.extract(py).unwrap();
7503            assert_eq!(text, "hello");
7504        });
7505    }
7506
7507    #[test]
7508    fn pty_buffer_decode_chunk_binary_mode() {
7509        pyo3::prepare_freethreaded_python();
7510        pyo3::Python::with_gil(|py| {
7511            let buf = NativePtyBuffer::new(false, "utf-8", "replace");
7512            let result = buf.decode_chunk(py, b"\xff\xfe").unwrap();
7513            let bytes: Vec<u8> = result.extract(py).unwrap();
7514            assert_eq!(bytes, vec![0xff, 0xfe]);
7515        });
7516    }
7517
7518    // ── NativePtyProcess mark_reader_closed / store_returncode tests ──
7519
7520    #[test]
7521    fn pty_process_mark_reader_closed() {
7522        pyo3::prepare_freethreaded_python();
7523        pyo3::Python::with_gil(|_py| {
7524            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
7525            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
7526            // reader should not be closed initially
7527            assert!(!process.reader.state.lock().unwrap().closed);
7528            process.mark_reader_closed();
7529            assert!(process.reader.state.lock().unwrap().closed);
7530        });
7531    }
7532
7533    #[test]
7534    fn pty_process_store_returncode_sets_value() {
7535        pyo3::prepare_freethreaded_python();
7536        pyo3::Python::with_gil(|_py| {
7537            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
7538            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
7539            assert!(process.returncode.lock().unwrap().is_none());
7540            process.store_returncode(42);
7541            assert_eq!(*process.returncode.lock().unwrap(), Some(42));
7542        });
7543    }
7544
7545    #[test]
7546    fn pty_process_record_input_metrics_tracks_data() {
7547        pyo3::prepare_freethreaded_python();
7548        pyo3::Python::with_gil(|_py| {
7549            let argv = vec!["python".to_string(), "-c".to_string(), "pass".to_string()];
7550            let process = NativePtyProcess::new(argv, None, None, 24, 80, None).unwrap();
7551            assert_eq!(process.pty_input_bytes_total(), 0);
7552            process.record_input_metrics(b"hello\n", false);
7553            assert_eq!(process.pty_input_bytes_total(), 6);
7554            assert_eq!(process.pty_newline_events_total(), 1);
7555            assert_eq!(process.pty_submit_events_total(), 0);
7556            process.record_input_metrics(b"\r", true);
7557            assert_eq!(process.pty_submit_events_total(), 1);
7558        });
7559    }
7560
7561    // ── process_err_to_py additional variants ──
7562
7563    #[test]
7564    fn process_err_to_py_already_started_is_runtime_error() {
7565        pyo3::prepare_freethreaded_python();
7566        let err = process_err_to_py(running_process_core::ProcessError::AlreadyStarted);
7567        pyo3::Python::with_gil(|py| {
7568            assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
7569        });
7570    }
7571
7572    #[test]
7573    fn process_err_to_py_stdin_unavailable_is_runtime_error() {
7574        pyo3::prepare_freethreaded_python();
7575        let err = process_err_to_py(running_process_core::ProcessError::StdinUnavailable);
7576        pyo3::Python::with_gil(|py| {
7577            assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
7578        });
7579    }
7580
7581    #[test]
7582    fn process_err_to_py_spawn_is_runtime_error() {
7583        pyo3::prepare_freethreaded_python();
7584        let err = process_err_to_py(running_process_core::ProcessError::Spawn(
7585            std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
7586        ));
7587        pyo3::Python::with_gil(|py| {
7588            assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
7589        });
7590    }
7591
7592    #[test]
7593    fn process_err_to_py_io_is_runtime_error() {
7594        pyo3::prepare_freethreaded_python();
7595        let err = process_err_to_py(running_process_core::ProcessError::Io(std::io::Error::new(
7596            std::io::ErrorKind::BrokenPipe,
7597            "broken pipe",
7598        )));
7599        pyo3::Python::with_gil(|py| {
7600            assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
7601        });
7602    }
7603
7604    // ── NativeRunningProcess: piped stdin tests ──
7605
7606    #[test]
7607    fn running_process_piped_stdin() {
7608        pyo3::prepare_freethreaded_python();
7609        pyo3::Python::with_gil(|py| {
7610            let cmd = pyo3::types::PyList::new(
7611                py,
7612                [
7613                    "python",
7614                    "-c",
7615                    "import sys; data=sys.stdin.buffer.read(); sys.stdout.buffer.write(data[::-1])",
7616                ],
7617            )
7618            .unwrap();
7619            let process = NativeRunningProcess::new(
7620                cmd.as_any(),
7621                None,
7622                false,
7623                true,
7624                None,
7625                None,
7626                true,
7627                None,
7628                None,
7629                "piped",
7630                "stdout",
7631                None,
7632                false,
7633            )
7634            .unwrap();
7635            process.start_impl().unwrap();
7636            process.inner.write_stdin(b"abc").unwrap();
7637            let code = process.wait_impl(py, Some(10.0)).unwrap();
7638            assert_eq!(code, 0);
7639        });
7640    }
7641
7642    // ── NativeRunningProcess: shell mode ──
7643
7644    #[test]
7645    fn running_process_shell_mode() {
7646        pyo3::prepare_freethreaded_python();
7647        pyo3::Python::with_gil(|py| {
7648            let cmd = pyo3::types::PyString::new(py, "echo shell-mode-test");
7649            let process = NativeRunningProcess::new(
7650                cmd.as_any(),
7651                None,
7652                true, // shell=true
7653                true,
7654                None,
7655                None,
7656                true,
7657                None,
7658                None,
7659                "inherit",
7660                "stdout",
7661                None,
7662                false,
7663            )
7664            .unwrap();
7665            process.start_impl().unwrap();
7666            let code = process.wait_impl(py, Some(10.0)).unwrap();
7667            assert_eq!(code, 0);
7668        });
7669    }
7670
7671    // ── NativeRunningProcess: send_interrupt before start errors ──
7672
7673    #[test]
7674    fn running_process_send_interrupt_before_start_errors() {
7675        pyo3::prepare_freethreaded_python();
7676        pyo3::Python::with_gil(|py| {
7677            let cmd = pyo3::types::PyList::new(py, ["python", "-c", "pass"]).unwrap();
7678            let process = NativeRunningProcess::new(
7679                cmd.as_any(),
7680                None,
7681                false,
7682                false,
7683                None,
7684                None,
7685                true,
7686                None,
7687                None,
7688                "inherit",
7689                "stdout",
7690                None,
7691                false,
7692            )
7693            .unwrap();
7694            assert!(process.send_interrupt_impl().is_err());
7695        });
7696    }
7697
7698    // ── NativeTerminalInput additional tests ──
7699
7700    #[test]
7701    fn terminal_input_inject_multiple_events() {
7702        let input = NativeTerminalInput::new();
7703        {
7704            let mut state = input.state.lock().unwrap();
7705            for i in 0..5 {
7706                state.events.push_back(TerminalInputEventRecord {
7707                    data: vec![b'a' + i],
7708                    submit: false,
7709                    shift: false,
7710                    ctrl: false,
7711                    alt: false,
7712                    virtual_key_code: 0,
7713                    repeat_count: 1,
7714                });
7715            }
7716        }
7717        assert!(input.available());
7718        let mut count = 0;
7719        while input.next_event().is_some() {
7720            count += 1;
7721        }
7722        assert_eq!(count, 5);
7723        assert!(!input.available());
7724    }
7725
7726    #[test]
7727    fn terminal_input_capturing_false_initially() {
7728        let input = NativeTerminalInput::new();
7729        assert!(!input.capturing());
7730    }
7731
7732    // ── NativeTerminalInputEvent fields ──
7733
7734    #[test]
7735    fn terminal_input_event_fields() {
7736        let event = NativeTerminalInputEvent {
7737            data: vec![0x1B, 0x5B, 0x41],
7738            submit: false,
7739            shift: true,
7740            ctrl: true,
7741            alt: false,
7742            virtual_key_code: 38,
7743            repeat_count: 2,
7744        };
7745        assert_eq!(event.data, vec![0x1B, 0x5B, 0x41]);
7746        assert!(!event.submit);
7747        assert!(event.shift);
7748        assert!(event.ctrl);
7749        assert!(!event.alt);
7750        assert_eq!(event.virtual_key_code, 38);
7751        assert_eq!(event.repeat_count, 2);
7752        // __repr__ should include all flags
7753        let repr = event.__repr__();
7754        assert!(repr.contains("shift=true"));
7755        assert!(repr.contains("ctrl=true"));
7756        assert!(repr.contains("alt=false"));
7757    }
7758
7759    // ── spawn_pty_reader test ──
7760
7761    #[test]
7762    fn spawn_pty_reader_reads_data_and_closes() {
7763        let shared = Arc::new(PtyReadShared {
7764            state: Mutex::new(PtyReadState {
7765                chunks: VecDeque::new(),
7766                closed: false,
7767            }),
7768            condvar: Condvar::new(),
7769        });
7770
7771        let data = b"hello from reader\n";
7772        let reader: Box<dyn std::io::Read + Send> = Box::new(std::io::Cursor::new(data.to_vec()));
7773        let echo = Arc::new(AtomicBool::new(false));
7774        let idle = Arc::new(Mutex::new(None));
7775        let out_bytes = Arc::new(AtomicUsize::new(0));
7776        let churn_bytes = Arc::new(AtomicUsize::new(0));
7777        spawn_pty_reader(
7778            reader,
7779            Arc::clone(&shared),
7780            echo,
7781            idle,
7782            out_bytes,
7783            churn_bytes,
7784        );
7785
7786        // Wait for the reader thread to finish
7787        let deadline = Instant::now() + Duration::from_secs(5);
7788        loop {
7789            let state = shared.state.lock().unwrap();
7790            if state.closed {
7791                break;
7792            }
7793            drop(state);
7794            assert!(Instant::now() < deadline, "reader thread did not close");
7795            std::thread::sleep(Duration::from_millis(10));
7796        }
7797
7798        let state = shared.state.lock().unwrap();
7799        assert!(state.closed);
7800        assert!(!state.chunks.is_empty());
7801    }
7802
7803    #[test]
7804    fn spawn_pty_reader_empty_input_closes() {
7805        let shared = Arc::new(PtyReadShared {
7806            state: Mutex::new(PtyReadState {
7807                chunks: VecDeque::new(),
7808                closed: false,
7809            }),
7810            condvar: Condvar::new(),
7811        });
7812
7813        let reader: Box<dyn std::io::Read + Send> = Box::new(std::io::Cursor::new(Vec::new()));
7814        let echo = Arc::new(AtomicBool::new(false));
7815        let idle = Arc::new(Mutex::new(None));
7816        let out_bytes = Arc::new(AtomicUsize::new(0));
7817        let churn_bytes = Arc::new(AtomicUsize::new(0));
7818        spawn_pty_reader(
7819            reader,
7820            Arc::clone(&shared),
7821            echo,
7822            idle,
7823            out_bytes,
7824            churn_bytes,
7825        );
7826
7827        let deadline = Instant::now() + Duration::from_secs(5);
7828        loop {
7829            let state = shared.state.lock().unwrap();
7830            if state.closed {
7831                break;
7832            }
7833            drop(state);
7834            assert!(Instant::now() < deadline, "reader thread did not close");
7835            std::thread::sleep(Duration::from_millis(10));
7836        }
7837
7838        let state = shared.state.lock().unwrap();
7839        assert!(state.closed);
7840        assert!(state.chunks.is_empty());
7841    }
7842
7843    // ── Windows-only: windows_generate_console_ctrl_break ──
7844
7845    #[test]
7846    #[cfg(windows)]
7847    fn windows_generate_console_ctrl_break_nonexistent_pid() {
7848        pyo3::prepare_freethreaded_python();
7849        // Nonexistent PID should error
7850        let result = windows_generate_console_ctrl_break_impl(999999, None);
7851        assert!(result.is_err());
7852    }
7853
7854    // ── NativeRunningProcess: with env ──
7855
7856    #[test]
7857    fn running_process_with_env() {
7858        pyo3::prepare_freethreaded_python();
7859        pyo3::Python::with_gil(|py| {
7860            let env = pyo3::types::PyDict::new(py);
7861            if let Ok(path) = std::env::var("PATH") {
7862                env.set_item("PATH", &path).unwrap();
7863            }
7864            #[cfg(windows)]
7865            if let Ok(root) = std::env::var("SystemRoot") {
7866                env.set_item("SystemRoot", &root).unwrap();
7867            }
7868            env.set_item("RP_TEST_VAR", "test_value").unwrap();
7869
7870            let cmd = pyo3::types::PyList::new(
7871                py,
7872                [
7873                    "python",
7874                    "-c",
7875                    "import os; print(os.environ.get('RP_TEST_VAR', 'MISSING'))",
7876                ],
7877            )
7878            .unwrap();
7879            let process = NativeRunningProcess::new(
7880                cmd.as_any(),
7881                None,
7882                false,
7883                true,
7884                Some(env),
7885                None,
7886                true,
7887                None,
7888                None,
7889                "inherit",
7890                "stdout",
7891                None,
7892                false,
7893            )
7894            .unwrap();
7895            process.start_impl().unwrap();
7896            let code = process.wait_impl(py, Some(10.0)).unwrap();
7897            assert_eq!(code, 0);
7898            let text = process
7899                .captured_stream_text(py, StreamKind::Stdout)
7900                .unwrap();
7901            assert!(text.contains("test_value"));
7902        });
7903    }
7904
7905    // ── Windows input_payload test ──
7906
7907    #[test]
7908    #[cfg(windows)]
7909    fn windows_pty_input_payload_via_module() {
7910        assert_eq!(pty_windows::input_payload(b"hello"), b"hello");
7911        assert_eq!(pty_windows::input_payload(b"\n"), b"\r");
7912    }
7913}
7914
7915// ── ContainedProcessGroup Python wrapper ────────────────────────────────────
7916
7917/// Python enum-like class for containment policy.
7918#[pyclass]
7919#[derive(Clone, Copy)]
7920struct PyContainment {
7921    inner: Containment,
7922}
7923
7924#[pymethods]
7925impl PyContainment {
7926    /// Create a "Contained" policy — child is killed when the group drops.
7927    #[staticmethod]
7928    fn contained() -> Self {
7929        Self {
7930            inner: Containment::Contained,
7931        }
7932    }
7933
7934    /// Create a "Detached" policy — child survives the group drop.
7935    #[staticmethod]
7936    fn detached() -> Self {
7937        Self {
7938            inner: Containment::Detached,
7939        }
7940    }
7941
7942    fn __repr__(&self) -> String {
7943        match self.inner {
7944            Containment::Contained => "Containment.Contained".to_string(),
7945            Containment::Detached => "Containment.Detached".to_string(),
7946        }
7947    }
7948}
7949
7950/// Python wrapper for `ContainedProcessGroup`.
7951#[pyclass(name = "ContainedProcessGroup")]
7952struct PyContainedProcessGroup {
7953    inner: Option<ContainedProcessGroup>,
7954    children: Vec<ContainedChild>,
7955}
7956
7957#[pymethods]
7958impl PyContainedProcessGroup {
7959    #[new]
7960    #[pyo3(signature = (originator=None))]
7961    fn new(originator: Option<String>) -> PyResult<Self> {
7962        let group = match originator {
7963            Some(ref orig) => ContainedProcessGroup::with_originator(orig).map_err(to_py_err)?,
7964            None => ContainedProcessGroup::new().map_err(to_py_err)?,
7965        };
7966        Ok(Self {
7967            inner: Some(group),
7968            children: Vec::new(),
7969        })
7970    }
7971
7972    #[getter]
7973    fn originator(&self) -> Option<String> {
7974        self.inner.as_ref()?.originator().map(String::from)
7975    }
7976
7977    #[getter]
7978    fn originator_value(&self) -> Option<String> {
7979        self.inner.as_ref()?.originator_value()
7980    }
7981
7982    /// Spawn a contained child process (killed when group drops).
7983    fn spawn(&mut self, argv: Vec<String>) -> PyResult<u32> {
7984        let group = self
7985            .inner
7986            .as_ref()
7987            .ok_or_else(|| PyRuntimeError::new_err("group already closed"))?;
7988        if argv.is_empty() {
7989            return Err(PyValueError::new_err("argv must not be empty"));
7990        }
7991        let mut cmd = std::process::Command::new(&argv[0]);
7992        if argv.len() > 1 {
7993            cmd.args(&argv[1..]);
7994        }
7995        let contained = group.spawn(&mut cmd).map_err(to_py_err)?;
7996        let pid = contained.child.id();
7997        self.children.push(contained);
7998        Ok(pid)
7999    }
8000
8001    /// Spawn a detached child process (survives group drop).
8002    fn spawn_detached(&mut self, argv: Vec<String>) -> PyResult<u32> {
8003        let group = self
8004            .inner
8005            .as_ref()
8006            .ok_or_else(|| PyRuntimeError::new_err("group already closed"))?;
8007        if argv.is_empty() {
8008            return Err(PyValueError::new_err("argv must not be empty"));
8009        }
8010        let mut cmd = std::process::Command::new(&argv[0]);
8011        if argv.len() > 1 {
8012            cmd.args(&argv[1..]);
8013        }
8014        let contained = group.spawn_detached(&mut cmd).map_err(to_py_err)?;
8015        let pid = contained.child.id();
8016        self.children.push(contained);
8017        Ok(pid)
8018    }
8019
8020    /// Close the group, killing all contained children.
8021    fn close(&mut self) {
8022        self.inner.take();
8023    }
8024
8025    /// Context manager: __enter__ returns self.
8026    fn __enter__(slf: Py<Self>) -> Py<Self> {
8027        slf
8028    }
8029
8030    /// Context manager: __exit__ closes the group.
8031    #[pyo3(signature = (_exc_type=None, _exc_val=None, _exc_tb=None))]
8032    fn __exit__(
8033        &mut self,
8034        _exc_type: Option<&Bound<'_, PyAny>>,
8035        _exc_val: Option<&Bound<'_, PyAny>>,
8036        _exc_tb: Option<&Bound<'_, PyAny>>,
8037    ) {
8038        self.close();
8039    }
8040}
8041
8042// ── Originator process scanning ─────────────────────────────────────────────
8043
8044#[pyclass(name = "OriginatorProcessInfo")]
8045#[derive(Clone)]
8046struct PyOriginatorProcessInfo {
8047    #[pyo3(get)]
8048    pid: u32,
8049    #[pyo3(get)]
8050    name: String,
8051    #[pyo3(get)]
8052    command: String,
8053    #[pyo3(get)]
8054    originator: String,
8055    #[pyo3(get)]
8056    parent_pid: u32,
8057    #[pyo3(get)]
8058    parent_alive: bool,
8059}
8060
8061#[pymethods]
8062impl PyOriginatorProcessInfo {
8063    fn __repr__(&self) -> String {
8064        format!(
8065            "OriginatorProcessInfo(pid={}, name={:?}, originator={:?}, parent_pid={}, parent_alive={})",
8066            self.pid, self.name, self.originator, self.parent_pid, self.parent_alive
8067        )
8068    }
8069}
8070
8071impl From<OriginatorProcessInfo> for PyOriginatorProcessInfo {
8072    fn from(info: OriginatorProcessInfo) -> Self {
8073        Self {
8074            pid: info.pid,
8075            name: info.name,
8076            command: info.command,
8077            originator: info.originator,
8078            parent_pid: info.parent_pid,
8079            parent_alive: info.parent_alive,
8080        }
8081    }
8082}
8083
8084/// Find all processes whose RUNNING_PROCESS_ORIGINATOR env var starts
8085/// with the given tool prefix.
8086#[pyfunction]
8087fn py_find_processes_by_originator(tool: &str) -> Vec<PyOriginatorProcessInfo> {
8088    find_processes_by_originator(tool)
8089        .into_iter()
8090        .map(PyOriginatorProcessInfo::from)
8091        .collect()
8092}
8093
8094#[pymodule]
8095fn _native(_py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
8096    module.add_class::<PyNativeProcess>()?;
8097    module.add_class::<NativeRunningProcess>()?;
8098    module.add_class::<PyContainedProcessGroup>()?;
8099    module.add_class::<PyContainment>()?;
8100    module.add_class::<PyOriginatorProcessInfo>()?;
8101    module.add_function(wrap_pyfunction!(py_find_processes_by_originator, module)?)?;
8102    module.add_class::<NativePtyProcess>()?;
8103    module.add_class::<NativeProcessMetrics>()?;
8104    module.add_class::<NativeSignalBool>()?;
8105    module.add_class::<NativeIdleDetector>()?;
8106    module.add_class::<NativePtyBuffer>()?;
8107    module.add_class::<NativeTerminalInput>()?;
8108    module.add_class::<NativeTerminalInputEvent>()?;
8109    module.add_function(wrap_pyfunction!(tracked_pid_db_path_py, module)?)?;
8110    module.add_function(wrap_pyfunction!(track_process_pid, module)?)?;
8111    module.add_function(wrap_pyfunction!(untrack_process_pid, module)?)?;
8112    module.add_function(wrap_pyfunction!(native_register_process, module)?)?;
8113    module.add_function(wrap_pyfunction!(native_unregister_process, module)?)?;
8114    module.add_function(wrap_pyfunction!(list_tracked_processes, module)?)?;
8115    module.add_function(wrap_pyfunction!(native_list_active_processes, module)?)?;
8116    module.add_function(wrap_pyfunction!(native_get_process_tree_info, module)?)?;
8117    module.add_function(wrap_pyfunction!(native_kill_process_tree, module)?)?;
8118    module.add_function(wrap_pyfunction!(native_process_created_at, module)?)?;
8119    module.add_function(wrap_pyfunction!(native_is_same_process, module)?)?;
8120    module.add_function(wrap_pyfunction!(native_cleanup_tracked_processes, module)?)?;
8121    module.add_function(wrap_pyfunction!(native_apply_process_nice, module)?)?;
8122    module.add_function(wrap_pyfunction!(
8123        native_windows_terminal_input_bytes,
8124        module
8125    )?)?;
8126    module.add_function(wrap_pyfunction!(native_dump_rust_debug_traces, module)?)?;
8127    module.add_function(wrap_pyfunction!(
8128        native_test_capture_rust_debug_trace,
8129        module
8130    )?)?;
8131    #[cfg(windows)]
8132    module.add_function(wrap_pyfunction!(native_test_hang_in_rust, module)?)?;
8133    module.add("VERSION", PyString::new(_py, env!("CARGO_PKG_VERSION")))?;
8134    Ok(())
8135}