Skip to main content

_native/
lib.rs

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