Skip to main content

_native/
lib.rs

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