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