Skip to main content

_native/
lib.rs

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