From e47f4786f2e078459c1e2342be3007b1a072a774 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:00:06 -0700 Subject: [PATCH 1/5] feat(terminal): bound native capture and coordinate input ownership --- docs/terminal-input.md | 32 ++++++++ src/platform/terminal.rs | 109 +++++++++++++++++++++++++ src/platform_linux/terminal.rs | 6 ++ src/platform_macos/terminal.rs | 12 ++- src/platform_win/terminal.rs | 1 + src/platform_win/terminal_input.rs | 126 ++++++++++++++++++++++++----- tests/terminal_input_ownership.rs | 92 +++++++++++++++++++++ tests/terminal_input_windows.rs | 99 +++++++++++++++++++++++ 8 files changed, 456 insertions(+), 21 deletions(-) create mode 100644 docs/terminal-input.md create mode 100644 tests/terminal_input_ownership.rs create mode 100644 tests/terminal_input_windows.rs diff --git a/docs/terminal-input.md b/docs/terminal-input.md new file mode 100644 index 00000000..696f9875 --- /dev/null +++ b/docs/terminal-input.md @@ -0,0 +1,32 @@ +# Native terminal input groundwork + +The `pty` feature exposes `TerminalInputSession`, an owned raw-terminal capture +session. `new` snapshots the input mode and Drop attempts to restore it. Unix +returns `Ok(None)` for non-terminal stdin; Windows currently reports an error +when stdin is not an attached console. Callers must not assume parity yet. + +Capture and active terminal-graphics probes share exclusive admission within +one kernel instance. A second capture returns `WouldBlock`; a probe declines +while capture owns input. This cannot coordinate other libraries, linked kernel +copies, or processes reading the same terminal. Restore failures during Drop +are best-effort, not a guarantee that the terminal mode was restored. + +Windows capture holds at most 256 translated events and 64 KiB of queued input. +Key releases are ignored; a key press with more than 1024 repetitions stops +capture before repeated text is allocated. Queue overflow and native read/wait +failure stop capture and are reported by the fallible wait APIs, including +`TerminalInputSession::read_chunk`. Previously delivered events cannot be +recalled. Optional trace I/O is outside the queue mutex, but may still delay +the worker and its shutdown; this API does not promise a shutdown deadline. + +`TerminalInputState` no longer exposes its Windows queue for external mutation. +The new capture-failure enum variants require downstream exhaustive matches to +be updated. Legacy `next_event` and `drain_events` do not report failures; new +consumers should use fallible waits instead. + +These are raw chunks, not decoded key events. In particular, scanning a chunk +for spaces or newlines is not safe key matching: terminal escape sequences and +pasted input can contain those bytes. Issue #178 still requires the bounded +key-polling and styling facade, FastLED policy adoption, native validation, +release, and exact published-version consumption. This groundwork does not +complete the Crossterm migration. diff --git a/src/platform/terminal.rs b/src/platform/terminal.rs index 200e018f..2647e94c 100644 --- a/src/platform/terminal.rs +++ b/src/platform/terminal.rs @@ -5,6 +5,80 @@ use std::io::{self, Read, Write}; use std::path::Path; use std::sync::{Arc, Mutex}; +static INPUT_OWNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +#[cfg(any(windows, all(test, feature = "pty")))] +pub(crate) struct InputQueue { + events: std::collections::VecDeque<(T, usize)>, + bytes: usize, +} + +#[cfg(any(windows, all(test, feature = "pty")))] +impl InputQueue { + pub(crate) fn new() -> Self { + Self { + events: std::collections::VecDeque::new(), + bytes: 0, + } + } + + pub(crate) fn push(&mut self, event: T, bytes: usize) -> bool { + if self.events.len() >= 256 || bytes > 65_536 - self.bytes { + return false; + } + self.events.push_back((event, bytes)); + self.bytes += bytes; + true + } + + pub(crate) fn pop_front(&mut self) -> Option { + self.events.pop_front().map(|(event, bytes)| { + self.bytes -= bytes; + event + }) + } + + pub(crate) fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub(crate) fn clear(&mut self) { + self.events.clear(); + self.bytes = 0; + } + + pub(crate) fn drain(&mut self) -> impl Iterator + '_ { + self.bytes = 0; + self.events.drain(..).map(|(event, _)| event) + } +} + +/// Admission shared by this kernel instance's stdin capture and tty probes. +/// It cannot coordinate unrelated libraries or another process reading the tty. +pub(crate) struct InputLease(()); + +impl InputLease { + pub(crate) fn acquire() -> io::Result { + INPUT_OWNED + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::Acquire, + std::sync::atomic::Ordering::Relaxed, + ) + .map(|_| Self(())) + .map_err(|_| { + io::Error::new(io::ErrorKind::WouldBlock, "terminal input is already owned") + }) + } +} + +impl Drop for InputLease { + fn drop(&mut self) { + INPUT_OWNED.store(false, std::sync::atomic::Ordering::Release); + } +} + /// Caller-facing PTY dimensions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PtySize { @@ -251,3 +325,38 @@ pub fn find_child_processes(parent_pid: u32) -> Vec { pub fn find_orphan_conhosts() -> Vec { crate::find_orphan_conhosts() } + +#[cfg(all(test, feature = "pty"))] +mod ownership_tests { + #[test] + fn capture_queue_enforces_event_and_byte_limits() { + let mut queue = super::InputQueue::new(); + for value in 0..256 { + assert!(queue.push(value, 1)); + } + assert!(!queue.push(999, 1)); + assert_eq!(queue.pop_front(), Some(0)); + assert!(queue.push(256, 1)); + queue.clear(); + assert!(queue.push(1, 65_536)); + assert!(!queue.push(2, 1)); + assert_eq!(queue.pop_front(), Some(1)); + assert!(!queue.push(3, 65_537)); + assert!(queue.is_empty()); + assert!(queue.push(4, 1)); + assert_eq!(queue.drain().collect::>(), [4]); + assert!(queue.push(5, 65_536)); + } + + #[test] + fn input_ownership_rejects_overlap_and_releases_on_drop() { + let first = super::InputLease::acquire().unwrap(); + assert_eq!( + super::InputLease::acquire().err().unwrap().kind(), + std::io::ErrorKind::WouldBlock + ); + drop(first); + let next = super::InputLease::acquire().unwrap(); + drop(next); + } +} diff --git a/src/platform_linux/terminal.rs b/src/platform_linux/terminal.rs index 28d1f479..79d91004 100644 --- a/src/platform_linux/terminal.rs +++ b/src/platform_linux/terminal.rs @@ -391,6 +391,7 @@ pub fn find_orphan_conhosts() -> Vec { pub struct TerminalInputSession { stdin_fd: i32, original_mode: libc::termios, + _input_lease: crate::platform::terminal::InputLease, } #[cfg(feature = "pty")] @@ -400,6 +401,7 @@ impl TerminalInputSession { if unsafe { libc::isatty(stdin_fd) } != 1 { return Ok(None); } + let input_lease = crate::platform::terminal::InputLease::acquire()?; let mut original_mode = std::mem::MaybeUninit::::uninit(); if unsafe { libc::tcgetattr(stdin_fd, original_mode.as_mut_ptr()) } != 0 { return Err(std::io::Error::last_os_error()); @@ -413,6 +415,7 @@ impl TerminalInputSession { Ok(Some(Self { stdin_fd, original_mode, + _input_lease: input_lease, })) } @@ -465,6 +468,9 @@ pub fn active_graphics_probe( use std::os::fd::AsRawFd as _; use std::time::Instant; + let Ok(_input_lease) = crate::platform::terminal::InputLease::acquire() else { + return crate::platform::terminal::TerminalGraphicsProbe::default(); + }; let Ok(mut tty) = OpenOptions::new().read(true).write(true).open("/dev/tty") else { return crate::platform::terminal::TerminalGraphicsProbe::default(); }; diff --git a/src/platform_macos/terminal.rs b/src/platform_macos/terminal.rs index 08f7cefe..c207eab1 100644 --- a/src/platform_macos/terminal.rs +++ b/src/platform_macos/terminal.rs @@ -342,20 +342,25 @@ pub fn find_child_processes(_parent_pid: u32) -> Vec { Vec::ne pub fn find_orphan_conhosts() -> Vec { Vec::new() } #[cfg(feature = "pty")] -pub struct TerminalInputSession { stdin_fd: i32, original_mode: libc::termios } +pub struct TerminalInputSession { + stdin_fd: i32, + original_mode: libc::termios, + _input_lease: crate::platform::terminal::InputLease, +} #[cfg(feature = "pty")] impl TerminalInputSession { pub fn new() -> std::io::Result> { let stdin_fd = libc::STDIN_FILENO; if unsafe { libc::isatty(stdin_fd) } != 1 { return Ok(None); } + let input_lease = crate::platform::terminal::InputLease::acquire()?; let mut original_mode = std::mem::MaybeUninit::::uninit(); if unsafe { libc::tcgetattr(stdin_fd, original_mode.as_mut_ptr()) } != 0 { return Err(std::io::Error::last_os_error()); } let original_mode = unsafe { original_mode.assume_init() }; let mut raw_mode = original_mode; unsafe { libc::cfmakeraw(&mut raw_mode) }; if unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw_mode) } != 0 { return Err(std::io::Error::last_os_error()); } - Ok(Some(Self { stdin_fd, original_mode })) + Ok(Some(Self { stdin_fd, original_mode, _input_lease: input_lease })) } pub fn read_chunk(&self, timeout: std::time::Duration) -> std::io::Result> { @@ -388,6 +393,9 @@ pub fn active_graphics_probe( use std::os::fd::AsRawFd as _; use std::time::Instant; + let Ok(_input_lease) = crate::platform::terminal::InputLease::acquire() else { + return crate::platform::terminal::TerminalGraphicsProbe::default(); + }; let Ok(mut tty) = OpenOptions::new().read(true).write(true).open("/dev/tty") else { return crate::platform::terminal::TerminalGraphicsProbe::default(); }; diff --git a/src/platform_win/terminal.rs b/src/platform_win/terminal.rs index 873b09d6..8ac5d6e1 100644 --- a/src/platform_win/terminal.rs +++ b/src/platform_win/terminal.rs @@ -346,6 +346,7 @@ impl TerminalInputSession { pub fn read_chunk(&self, timeout: std::time::Duration) -> std::io::Result> { use super::terminal_input::{TerminalInputWaitOutcome, wait_for_terminal_input_event}; match wait_for_terminal_input_event(&self.0.state, &self.0.condvar, Some(timeout)) { + TerminalInputWaitOutcome::Failed(error) => Err(std::io::Error::other(error)), TerminalInputWaitOutcome::Event(event) => Ok(Some(PtyInputChunk { data: event.data, submit: event.submit })), TerminalInputWaitOutcome::Timeout => Ok(None), TerminalInputWaitOutcome::Closed => Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "native terminal input closed")), diff --git a/src/platform_win/terminal_input.rs b/src/platform_win/terminal_input.rs index 0255e357..646dde87 100644 --- a/src/platform_win/terminal_input.rs +++ b/src/platform_win/terminal_input.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use crate::platform::terminal::InputQueue; #[cfg(windows)] use std::fs::OpenOptions; #[cfg(windows)] @@ -19,6 +19,8 @@ pub const NATIVE_TERMINAL_INPUT_TRACE_PATH_ENV: &str = /// Errors returned by native terminal input capture. #[derive(Debug, Error)] pub enum TerminalInputError { + #[error("terminal capture failed: {0}")] + Capture(#[from] TerminalCaptureFailure), /// Terminal input capture has already closed. #[error("terminal input is closed")] Closed, @@ -57,14 +59,25 @@ pub struct TerminalInputEventRecord { /// Shared queue state for captured terminal input events. pub struct TerminalInputState { /// Queued translated terminal input events. - pub events: VecDeque, + events: InputQueue, + failure: Option, /// Whether the capture stream has been closed. pub closed: bool, } +/// Capture stops and reports failure instead of silently dropping excess input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)] +pub enum TerminalCaptureFailure { + #[error("terminal input exceeded queue or repeat limits")] + Overflow, + #[error("native terminal input failed")] + Native, +} + #[cfg(windows)] /// Windows console state saved while native input capture is active. pub struct ActiveTerminalInputCapture { + _input_lease: crate::platform::terminal::InputLease, /// Raw Windows console input handle as an integer. pub input_handle: usize, /// Console mode to restore when capture stops. @@ -77,6 +90,7 @@ pub struct ActiveTerminalInputCapture { /// Result of waiting for a Windows terminal input event. #[derive(Debug, PartialEq)] pub enum TerminalInputWaitOutcome { + Failed(TerminalCaptureFailure), /// A translated input event was received. Event(TerminalInputEventRecord), /// Terminal input capture closed before an event arrived. @@ -498,6 +512,21 @@ pub(crate) fn translate_console_key_event( // ── Worker thread ── +#[cfg(windows)] +fn translate_captured_key_event( + record: &winapi::um::wincon::KEY_EVENT_RECORD, +) -> Result, TerminalCaptureFailure> { + // Releases carry no text. Ignore them before inspecting the repeat field. + if record.bKeyDown == 0 { + return Ok(None); + } + // Bound expansion before translation allocates repeated bytes. + if record.wRepeatCount > 1024 { + return Err(TerminalCaptureFailure::Overflow); + } + Ok(translate_console_key_event(record)) +} + #[cfg(windows)] /// Runs the Windows console input worker that queues translated key events. pub fn native_terminal_input_worker( @@ -522,7 +551,7 @@ pub fn native_terminal_input_worker( unix_now_seconds(), )); - while !stop.load(Ordering::Acquire) { + 'capture: while !stop.load(Ordering::Acquire) { let wait_result = unsafe { WaitForSingleObject(handle, 50) }; match wait_result { WAIT_OBJECT_0 => { @@ -536,31 +565,44 @@ pub fn native_terminal_input_worker( ) }; if ok == 0 { + state.lock().expect("terminal input mutex poisoned").failure = + Some(TerminalCaptureFailure::Native); append_native_terminal_input_trace_line(&format!( "[{:.6}] native_terminal_input read_console_input_failed handle={input_handle}", unix_now_seconds(), )); break; } - let mut batch = Vec::new(); for record in records.iter().take(read_count as usize) { if record.EventType != KEY_EVENT { continue; } let key_event = unsafe { record.Event.KeyEvent() }; - if let Some(event) = translate_console_key_event(key_event) { - batch.push(event); - } - } - if !batch.is_empty() { + // Translation can write the optional diagnostic trace. Never + // hold the input-state mutex across that external I/O. + let translated = translate_captured_key_event(key_event); let mut guard = state.lock().expect("terminal input mutex poisoned"); - guard.events.extend(batch); - drop(guard); - condvar.notify_all(); + match translated { + Err(failure) => { + guard.failure = Some(failure); + break 'capture; + } + Ok(Some(event)) => { + let bytes = event.data.len(); + if !guard.events.push(event, bytes) { + guard.failure = Some(TerminalCaptureFailure::Overflow); + break 'capture; + } + condvar.notify_all(); + } + Ok(None) => {} + } } } WAIT_TIMEOUT => continue, _ => { + state.lock().expect("terminal input mutex poisoned").failure = + Some(TerminalCaptureFailure::Native); append_native_terminal_input_trace_line(&format!( "[{:.6}] native_terminal_input wait_result={wait_result} handle={input_handle}", unix_now_seconds(), @@ -573,6 +615,9 @@ pub fn native_terminal_input_worker( capturing.store(false, Ordering::Release); let mut guard = state.lock().expect("terminal input mutex poisoned"); guard.closed = true; + if guard.failure.is_some() { + guard.events.clear(); + } condvar.notify_all(); drop(guard); append_native_terminal_input_trace_line(&format!( @@ -593,6 +638,9 @@ pub fn wait_for_terminal_input_event( let deadline = timeout.map(|limit| Instant::now() + limit); let mut guard = state.lock().expect("terminal input mutex poisoned"); loop { + if let Some(failure) = guard.failure { + return TerminalInputWaitOutcome::Failed(failure); + } if let Some(event) = guard.events.pop_front() { return TerminalInputWaitOutcome::Event(event); } @@ -652,7 +700,8 @@ impl TerminalInputCore { pub fn new() -> Self { Self { state: Arc::new(Mutex::new(TerminalInputState { - events: VecDeque::new(), + events: InputQueue::new(), + failure: None, closed: true, })), condvar: Arc::new(Condvar::new()), @@ -734,6 +783,9 @@ impl TerminalInputCore { let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs)); let mut guard = state.lock().expect("terminal input mutex poisoned"); loop { + if let Some(failure) = guard.failure { + return Err(TerminalInputError::Capture(failure)); + } if let Some(event) = guard.events.pop_front() { return Ok(event); } @@ -762,23 +814,23 @@ impl TerminalInputCore { /// Drains all queued terminal input events. pub fn drain_events(&self) -> Vec { let mut guard = self.state.lock().expect("terminal input mutex poisoned"); - guard.events.drain(..).collect() + guard.events.drain().collect() } /// Stops native terminal input capture and restores console state. pub fn stop_impl(&self) -> Result<(), std::io::Error> { - self.stop.store(true, Ordering::Release); #[cfg(windows)] append_native_terminal_input_trace_line(&format!( "[{:.6}] native_terminal_input stop_requested", unix_now_seconds(), )); - if let Some(worker) = self + let mut worker_guard = self .worker .lock() - .expect("terminal input worker mutex poisoned") - .take() - { + .expect("terminal input worker mutex poisoned"); + self.stop.store(true, Ordering::Release); + // Serialize restart until the old worker exits and its mode is restored. + if let Some(worker) = worker_guard.take() { let _ = worker.join(); } self.capturing.store(false, Ordering::Release); @@ -833,6 +885,7 @@ impl TerminalInputCore { return Err(std::io::Error::last_os_error()); } + let input_lease = crate::platform::terminal::InputLease::acquire()?; let mut original_mode = 0u32; let got_mode = unsafe { GetConsoleMode(input_handle, &mut original_mode) }; if got_mode == 0 { @@ -860,12 +913,14 @@ impl TerminalInputCore { { let mut state = self.state.lock().expect("terminal input mutex poisoned"); state.events.clear(); + state.failure = None; state.closed = false; } *self .console .lock() .expect("terminal input console mutex poisoned") = Some(ActiveTerminalInputCapture { + _input_lease: input_lease, input_handle: input_handle as usize, original_mode, active_mode, @@ -948,6 +1003,39 @@ pub(crate) fn key_event( event } +#[test] +fn capture_repeat_limit_precedes_translation_but_ignores_releases() { + let mut record = key_event(0, b' ' as u16, 0, 1025); + record.bKeyDown = 0; + assert_eq!(translate_captured_key_event(&record), Ok(None)); + record.bKeyDown = 1; + assert_eq!(translate_captured_key_event(&record), Err(TerminalCaptureFailure::Overflow)); + record.wRepeatCount = 1024; + assert_eq!(translate_captured_key_event(&record).unwrap().unwrap().data, vec![b' '; 1024]); +} + +#[test] +fn capture_queue_overflow_is_reported_before_queued_input() { + let core = TerminalInputCore::new(); + { + let mut state = core.state.lock().unwrap(); + let record = key_event(0, b' ' as u16, 0, 1); + for _ in 0..256 { + let event = translate_captured_key_event(&record).unwrap().unwrap(); + assert!(state.events.push(event, 1)); + } + let event = translate_captured_key_event(&record).unwrap().unwrap(); + assert!(!state.events.push(event, 1)); + state.failure = Some(TerminalCaptureFailure::Overflow); + assert_eq!(state.failure, Some(TerminalCaptureFailure::Overflow)); + } + assert_eq!( + wait_for_terminal_input_event(&core.state, &core.condvar, Some(Duration::ZERO)), + TerminalInputWaitOutcome::Failed(TerminalCaptureFailure::Overflow) + ); + assert!(matches!(core.wait_for_event(Some(0.0)), Err(TerminalInputError::Capture(TerminalCaptureFailure::Overflow)))); +} + #[test] fn native_terminal_input_mode_disables_cooked_console_flags() { let original_mode = diff --git a/tests/terminal_input_ownership.rs b/tests/terminal_input_ownership.rs new file mode 100644 index 00000000..f0cbeddf --- /dev/null +++ b/tests/terminal_input_ownership.rs @@ -0,0 +1,92 @@ +#![cfg(all(unix, feature = "pty"))] + +use std::io; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn stdin_mode() -> libc::termios { + let mut mode = std::mem::MaybeUninit::uninit(); + // SAFETY: mode is writable and stdin is a live PTY slave in the child. + assert_eq!(unsafe { libc::tcgetattr(0, mode.as_mut_ptr()) }, 0); + // SAFETY: successful tcgetattr initialized mode. + unsafe { mode.assume_init() } +} + +#[test] +fn native_session_rejects_overlap_and_restores_mode() { + if std::env::var_os("KERNAL_INPUT_OWNERSHIP_CHILD").is_some() { + let before = stdin_mode(); + assert_ne!(before.c_lflag & libc::ICANON, 0); + let session = kernal_api::TerminalInputSession::new().unwrap().unwrap(); + assert_eq!(stdin_mode().c_lflag & libc::ICANON, 0); + assert_eq!( + kernal_api::TerminalInputSession::new() + .err() + .unwrap() + .kind(), + io::ErrorKind::WouldBlock + ); + // The probe must not steal input or change modes while capture owns it. + let probe = kernal_api::platform::terminal::active_graphics_probe(Duration::ZERO); + assert!(probe.kitty_graphics.is_none()); + assert_eq!(stdin_mode().c_lflag & libc::ICANON, 0); + drop(session); + let after = stdin_mode(); + assert_eq!(before.c_iflag, after.c_iflag); + assert_eq!(before.c_oflag, after.c_oflag); + assert_eq!(before.c_cflag, after.c_cflag); + assert_eq!(before.c_lflag, after.c_lflag); + assert_eq!(before.c_cc, after.c_cc); + drop(kernal_api::TerminalInputSession::new().unwrap().unwrap()); + return; + } + + let mut master = -1; + let mut slave = -1; + // SAFETY: valid output pointers; null optional arguments request defaults. + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + // SAFETY: successful openpty returns two fresh owned descriptors. + let _master = unsafe { OwnedFd::from_raw_fd(master) }; + // SAFETY: slave is the other fresh descriptor, transferred to child stdin. + let slave = unsafe { OwnedFd::from_raw_fd(slave) }; + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "native_session_rejects_overlap_and_restores_mode", + "--nocapture", + ]) + .env("KERNAL_INPUT_OWNERSHIP_CHILD", "1") + .stdin(Stdio::from(slave)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("terminal ownership child timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/terminal_input_windows.rs b/tests/terminal_input_windows.rs new file mode 100644 index 00000000..58120223 --- /dev/null +++ b/tests/terminal_input_windows.rs @@ -0,0 +1,99 @@ +#![cfg(all(windows, feature = "pty"))] + +use std::io; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn native_console_session_restores_mode_and_excludes_overlap() { + if std::env::var_os("KERNAL_CONSOLE_OWNERSHIP_CHILD").is_some() { + use winapi::um::consoleapi::{AllocConsole, GetConsoleMode}; + use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING}; + use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE}; + use winapi::um::processenv::SetStdHandle; + use winapi::um::winbase::STD_INPUT_HANDLE; + use winapi::um::wincon::FreeConsole; + use winapi::um::winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE}; + + // This child owns a fresh console; never change the test runner's console. + // SAFETY: detaching this process does not detach its parent. + unsafe { FreeConsole() }; + // SAFETY: AllocConsole takes no pointers and attaches only this process. + assert_ne!( + unsafe { AllocConsole() }, + 0, + "{}", + io::Error::last_os_error() + ); + let name: Vec = "CONIN$\0".encode_utf16().collect(); + // SAFETY: name is terminated; null security/template request defaults. + let input = unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null_mut(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + }; + assert_ne!(input, INVALID_HANDLE_VALUE); + // SAFETY: input is a live console handle owned by this child. + assert_ne!(unsafe { SetStdHandle(STD_INPUT_HANDLE, input) }, 0); + let mode = || { + let mut value = 0; + // SAFETY: input remains live and value is writable. + assert_ne!(unsafe { GetConsoleMode(input, &mut value) }, 0); + value + }; + let before = mode(); + let session = kernal_api::TerminalInputSession::new().unwrap().unwrap(); + assert_ne!(before, mode()); + assert_eq!( + kernal_api::TerminalInputSession::new() + .err() + .unwrap() + .kind(), + io::ErrorKind::WouldBlock + ); + drop(session); + assert_eq!(before, mode()); + drop(kernal_api::TerminalInputSession::new().unwrap().unwrap()); + assert_eq!(before, mode()); + // SAFETY: all capture workers have joined before this owned handle closes. + assert_ne!(unsafe { CloseHandle(input) }, 0); + // SAFETY: releases only this child's console attachment. + unsafe { FreeConsole() }; + return; + } + + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "native_console_session_restores_mode_and_excludes_overlap", + "--nocapture", + ]) + .env("KERNAL_CONSOLE_OWNERSHIP_CHILD", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("console ownership child timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} From 22c0a1f0f16fdf3aa78cc20c07455a0e99ee86a2 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:11:40 -0700 Subject: [PATCH 2/5] feat(terminal): add bounded decoded key polling --- docs/terminal-input.md | 42 ++++- src/keys.rs | 304 ++++++++++++++++++++++++++++++ src/lib.rs | 4 + tests/terminal_input_ownership.rs | 23 ++- tests/terminal_keys.rs | 111 +++++++++++ 5 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 src/keys.rs create mode 100644 tests/terminal_keys.rs diff --git a/docs/terminal-input.md b/docs/terminal-input.md index 696f9875..beafa351 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -26,7 +26,43 @@ consumers should use fallible waits instead. These are raw chunks, not decoded key events. In particular, scanning a chunk for spaces or newlines is not safe key matching: terminal escape sequences and -pasted input can contain those bytes. Issue #178 still requires the bounded -key-polling and styling facade, FastLED policy adoption, native validation, -release, and exact published-version consumption. This groundwork does not +pasted input can contain those bytes. Use the decoded API below for key policy. + +## Decoded keys + +`keys::TerminalKeys` owns the existing raw capture session and returns one +facade-owned `KeyEvent` per `poll`. Calls accept a requested wait of at most +100 ms and examine at most 64 KiB of bytes. Remaining queued bytes survive +subsequent calls, including zero-wait calls. Drop uses the native session's +best-effort restoration. Raw capture changes signal-key behavior: consumers +must handle the decoded control-C event rather than rely on a cooked-terminal +signal. Opening still has the platform-specific non-terminal behavior above. + +`keys::KeyDecoder` is the same incremental decoder without native ownership; +callers feeding bytes directly own its timing and use `finish_pending` after +their inter-byte deadline. TerminalKeys checks incomplete-sequence age on polls +when no previously buffered bytes remain: after 250 ms a lone Escape becomes an +Escape event and other partial sequences fail. Poll timing is a requested OS +wait plus bounded parsing, not a hard real-time guarantee. + +The small contract recognizes UTF-8 characters, Enter, Escape and legacy +control characters and unambiguous Alt characters. Alt+Space and reserved Alt +introducers overlap terminal control-sequence encodings; they are conservatively +parsed as control sequences, not text keys. An incomplete sequence then fails +closed. Consumers must not assume full legacy-modifier equivalence with Crossterm. +Shift and key-release information cannot be recovered +from legacy terminal bytes. Windows capture ignores releases and expands +bounded repeats. CR/LF mean Enter; their legacy control-letter aliases are not +distinguished. CSI/SS3 and intermediate escape sequences produce `Other`, as do +complete bracketed pastes and control strings. Plain unbracketed paste cannot +be distinguished from typed text. Unsupported X10 mouse payloads fail closed. +This is not a general terminal emulator or a Crossterm event-type wrapper. + +Decoder buffering is at most 128 bytes. A control string or bracketed paste +may consume at most 64 KiB including its introducer and terminator, without +buffering the body. Invalid UTF-8, malformed or oversized sequences poison the +decoder: no trailing spaces are reinterpreted as keys after an error. + +Issue #178 still requires decoded-key native validation, styling, FastLED policy +adoption, release and exact published-version consumption. This does not yet complete the Crossterm migration. diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 00000000..5c34d8a3 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,304 @@ +//! Small terminal-key contract, not a complete terminal emulator. +//! +//! UTF-8 characters, Enter, Escape and unambiguous legacy control/Alt characters are +//! decoded. Other escape sequences and bracketed paste produce `Other`, never +//! their embedded characters. Unbracketed paste is indistinguishable from typed +//! text. Releases are absent from byte protocols; Windows capture ignores them +//! and expands bounded repeats. Shift cannot be recovered from legacy bytes. +//! Ambiguous Alt encodings (including Alt+Space) are conservatively parsed as +//! escape sequences, not text keys; incomplete sequences fail closed. + +use std::io; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Key { + Character(char), + Enter, + Escape, + Other, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct KeyModifiers { + pub control: bool, + pub alt: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyEvent { + pub key: Key, + pub modifiers: KeyModifiers, +} + +#[derive(Default)] +enum State { + #[default] + Ground, + Escape, + EscapeIntermediate, + Utf8 { + length: usize, + alt: bool, + }, + Csi, + Ss3, + Paste { + matched: usize, + }, + String { + bell: bool, + escape: bool, + }, + Failed, +} + +/// Incremental decoder with at most 128 buffered bytes and 64 KiB per control +/// string/paste. Errors poison the decoder: trailing input cannot become keys. +/// Callers own timing; [`TerminalKeys`] also bounds incomplete-sequence time. +#[derive(Default)] +pub struct KeyDecoder { + state: State, + pending: Vec, + consumed: usize, +} + +impl KeyDecoder { + pub fn new() -> Self { + Self::default() + } + + fn failure(&mut self) -> io::Error { + self.state = State::Failed; + self.pending.clear(); + io::Error::new( + io::ErrorKind::InvalidData, + "malformed or oversized terminal key sequence", + ) + } + + fn event(&mut self, key: Key, control: bool, alt: bool) -> Option { + self.state = State::Ground; + self.pending.clear(); + self.consumed = 0; + Some(KeyEvent { + key, + modifiers: KeyModifiers { control, alt }, + }) + } + + fn character(&mut self, byte: u8, alt: bool) -> io::Result> { + let key = match byte { + b'\r' | b'\n' => Key::Enter, + 1..=26 => { + return Ok(self.event(Key::Character(char::from(b'a' + byte - 1)), true, alt)) + } + 32..=126 => Key::Character(char::from(byte)), + 0 | 28..=31 | 127 => Key::Other, + 0xc2..=0xf4 => { + let length = if byte < 0xe0 { + 2 + } else if byte < 0xf0 { + 3 + } else { + 4 + }; + self.pending.push(byte); + self.state = State::Utf8 { length, alt }; + return Ok(None); + } + _ => return Err(self.failure()), + }; + Ok(self.event(key, false, alt)) + } + + /// Feed one byte. `None` means a sequence is incomplete, not a key release. + pub fn push(&mut self, byte: u8) -> io::Result> { + self.consumed += 1; + if self.consumed > 65_536 { + return Err(self.failure()); + } + match self.state { + State::Failed => Err(self.failure()), + State::Ground if byte == 27 => { + self.state = State::Escape; + Ok(None) + } + State::Ground => self.character(byte, false), + State::Escape => match byte { + b'[' => { + self.state = State::Csi; + Ok(None) + } + b'O' => { + self.state = State::Ss3; + Ok(None) + } + 27 => { + let event = self.event(Key::Escape, false, false); + self.state = State::Escape; + self.consumed = 1; + Ok(event) + } + b']' | b'P' | b'_' | b'^' | b'X' => { + self.state = State::String { + bell: byte == b']', + escape: false, + }; + Ok(None) + } + 0x20..=0x2f => { + self.state = State::EscapeIntermediate; + Ok(None) + } + _ => self.character(byte, true), + }, + State::EscapeIntermediate => match byte { + 0x20..=0x2f if self.consumed <= 128 => Ok(None), + 0x30..=0x7e => Ok(self.event(Key::Other, false, false)), + _ => Err(self.failure()), + }, + State::Utf8 { length, alt } => { + self.pending.push(byte); + if self.pending.len() < length { + return Ok(None); + } + let character = std::str::from_utf8(&self.pending) + .ok() + .and_then(|text| text.chars().next()); + match character { + Some(character) => Ok(self.event(Key::Character(character), false, alt)), + None => Err(self.failure()), + } + } + State::Csi | State::Ss3 => { + let csi = matches!(self.state, State::Csi); + if self.pending.len() >= 128 { + return Err(self.failure()); + } + self.pending.push(byte); + match byte { + 0x20..=0x3f => Ok(None), + 0x40..=0x7e if csi && self.pending == b"200~" => { + self.pending.clear(); + self.state = State::Paste { matched: 0 }; + Ok(None) + } + // X10 mouse packets have a raw payload after this final. + // They are unsupported, not standalone keys followed by text. + b'M' if csi && self.pending == b"M" => Err(self.failure()), + 0x40..=0x7e => Ok(self.event(Key::Other, false, false)), + _ => Err(self.failure()), + } + } + State::Paste { matched } => { + let marker = b"\x1b[201~"; + let matched = if byte == marker[matched] { + matched + 1 + } else { + usize::from(byte == 27) + }; + if matched == marker.len() { + return Ok(self.event(Key::Other, false, false)); + } + self.state = State::Paste { matched }; + Ok(None) + } + State::String { bell, escape } => { + if (bell && byte == 7) || (escape && byte == b'\\') { + return Ok(self.event(Key::Other, false, false)); + } + self.state = State::String { + bell, + escape: byte == 27, + }; + Ok(None) + } + } + } + + /// Resolve a lone Escape after the caller's inter-byte wait. Other partial + /// sequences are errors, so their trailing spaces can never escape parsing. + pub fn finish_pending(&mut self) -> io::Result> { + match self.state { + State::Ground => Ok(None), + State::Escape => Ok(self.event(Key::Escape, false, false)), + _ => Err(self.failure()), + } + } +} + +/// Exclusive raw input owner. Drop restores modes best-effort via the existing +/// native session. Raw capture changes signal-key behavior: the application +/// must handle control-C events. No background listener or channel is added. +pub struct TerminalKeys { + input: crate::TerminalInputSession, + decoder: KeyDecoder, + bytes: Vec, + cursor: usize, + partial_since: Option, +} + +impl TerminalKeys { + pub fn new() -> io::Result> { + crate::TerminalInputSession::new().map(|input| { + input.map(|input| Self { + input, + decoder: KeyDecoder::new(), + bytes: Vec::new(), + cursor: 0, + partial_since: None, + }) + }) + } + + /// Return one key with a maximum requested wait of 100 ms. A poll examines + /// at most 64 KiB; queued bytes survive subsequent polls. Incomplete input + /// expires after 250 ms, checked on each poll (a lone Escape becomes a key). + pub fn poll(&mut self, wait: Duration) -> io::Result> { + if wait > Duration::from_millis(100) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal poll exceeds 100 ms", + )); + } + if self.cursor == self.bytes.len() + && self + .partial_since + .is_some_and(|start| start.elapsed() >= Duration::from_millis(250)) + { + self.partial_since = None; + return self.decoder.finish_pending(); + } + let deadline = Instant::now() + wait; + for _ in 0..65_536 { + if self.cursor == self.bytes.len() { + self.bytes.clear(); + self.cursor = 0; + let Some(chunk) = self + .input + .read_chunk(deadline.saturating_duration_since(Instant::now()))? + else { + return Ok(None); + }; + self.bytes = chunk.data; + if self.bytes.is_empty() { + return Ok(None); + } + } + let byte = self.bytes[self.cursor]; + self.cursor += 1; + match self.decoder.push(byte)? { + Some(event) => { + self.partial_since = + (!matches!(self.decoder.state, State::Ground)).then(Instant::now); + return Ok(Some(event)); + } + None => { + self.partial_since.get_or_insert_with(Instant::now); + } + } + } + Ok(None) + } +} diff --git a/src/lib.rs b/src/lib.rs index 95df412c..747e0df6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,10 @@ pub mod random; #[cfg(feature = "text-similarity")] pub mod text; +/// Bounded terminal key decoding and owned native polling. +#[cfg(feature = "pty")] +pub mod keys; + /// Bounded extraction into caller-exclusive staging directories. #[cfg(feature = "archive")] pub mod archive; diff --git a/tests/terminal_input_ownership.rs b/tests/terminal_input_ownership.rs index f0cbeddf..83c7dbf5 100644 --- a/tests/terminal_input_ownership.rs +++ b/tests/terminal_input_ownership.rs @@ -1,6 +1,7 @@ #![cfg(all(unix, feature = "pty"))] use std::io; +use std::io::Write; use std::os::fd::{FromRawFd, OwnedFd}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -39,6 +40,23 @@ fn native_session_rejects_overlap_and_restores_mode() { assert_eq!(before.c_lflag, after.c_lflag); assert_eq!(before.c_cc, after.c_cc); drop(kernal_api::TerminalInputSession::new().unwrap().unwrap()); + let mut keys = kernal_api::keys::TerminalKeys::new().unwrap().unwrap(); + use kernal_api::keys::Key; + for expected in [ + Key::Other, + Key::Character(' '), + Key::Character('é'), + Key::Enter, + ] { + assert_eq!(keys.poll(Duration::ZERO).unwrap().unwrap().key, expected); + } + assert!(keys.poll(Duration::ZERO).unwrap().is_none()); + assert_eq!( + keys.poll(Duration::from_secs(1)).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + drop(keys); + assert_eq!(before.c_lflag, stdin_mode().c_lflag); return; } @@ -58,7 +76,10 @@ fn native_session_rejects_overlap_and_restores_mode() { 0 ); // SAFETY: successful openpty returns two fresh owned descriptors. - let _master = unsafe { OwnedFd::from_raw_fd(master) }; + let mut master = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(master) }); + // This complete canonical line is queued before the child starts. Raw + // capture uses TCSANOW, preserving bytes for zero-wait decoding tests. + master.write_all(b"\x1b[12 z \xc3\xa9\r").unwrap(); // SAFETY: slave is the other fresh descriptor, transferred to child stdin. let slave = unsafe { OwnedFd::from_raw_fd(slave) }; let mut child = Command::new(std::env::current_exe().unwrap()) diff --git a/tests/terminal_keys.rs b/tests/terminal_keys.rs new file mode 100644 index 00000000..e8f273ac --- /dev/null +++ b/tests/terminal_keys.rs @@ -0,0 +1,111 @@ +#![cfg(feature = "pty")] + +use kernal_api::keys::{Key, KeyDecoder, KeyModifiers}; + +#[test] +fn decoded_keys_preserve_fragments_and_do_not_scan_control_sequences() { + let mut decoder = KeyDecoder::new(); + assert_eq!( + decoder.push(b' ').unwrap().unwrap().key, + Key::Character(' ') + ); + assert_eq!(decoder.push(b'\r').unwrap().unwrap().key, Key::Enter); + for byte in b"\x1b[12 " { + assert!(decoder.push(*byte).unwrap().is_none()); + } + assert_eq!(decoder.push(b'z').unwrap().unwrap().key, Key::Other); + assert!(decoder.push(0xc3).unwrap().is_none()); + assert_eq!( + decoder.push(0xa9).unwrap().unwrap().key, + Key::Character('é') + ); + let control = decoder.push(3).unwrap().unwrap(); + assert_eq!(control.key, Key::Character('c')); + assert_eq!( + control.modifiers, + KeyModifiers { + control: true, + alt: false + } + ); +} + +#[test] +fn bracketed_paste_and_control_strings_are_not_key_presses() { + let mut decoder = KeyDecoder::new(); + for byte in b"\x1b[200~hello \r world\x1b[201" { + assert!(decoder.push(*byte).unwrap().is_none()); + } + assert_eq!(decoder.push(b'~').unwrap().unwrap().key, Key::Other); + for byte in b"\x1b]title with spaces" { + assert!(decoder.push(*byte).unwrap().is_none()); + } + assert_eq!(decoder.push(7).unwrap().unwrap().key, Key::Other); +} + +#[test] +fn malformed_or_oversized_input_fails_closed() { + let mut decoder = KeyDecoder::new(); + assert!(decoder.push(0xff).is_err()); + assert!(decoder.push(b' ').is_err()); + let mut decoder = KeyDecoder::new(); + for byte in b"\x1b[" { + decoder.push(*byte).unwrap(); + } + let mut exceeded = false; + for _ in 0..129 { + if decoder.push(b'1').is_err() { + exceeded = true; + break; + } + } + assert!(exceeded); + assert!(decoder.push(b' ').is_err()); +} + +#[test] +fn function_keys_escape_repeats_and_unsupported_mouse_are_not_text() { + let mut decoder = KeyDecoder::new(); + for byte in b"\x1bO " { + assert!(decoder.push(*byte).unwrap().is_none()); + } + assert_eq!(decoder.push(b'A').unwrap().unwrap().key, Key::Other); + assert!(decoder.push(27).unwrap().is_none()); + assert_eq!(decoder.push(27).unwrap().unwrap().key, Key::Escape); + assert_eq!(decoder.finish_pending().unwrap().unwrap().key, Key::Escape); + for byte in b"\x1b[" { + decoder.push(*byte).unwrap(); + } + assert!(decoder.push(b'M').is_err()); + assert!(decoder.push(b' ').is_err()); +} + +#[test] +fn oversized_paste_and_incomplete_input_poison_the_decoder() { + let mut decoder = KeyDecoder::new(); + for byte in b"\x1b[200~" { + decoder.push(*byte).unwrap(); + } + for _ in 0..65_530 { + assert!(decoder.push(b' ').unwrap().is_none()); + } + assert!(decoder.push(b' ').is_err()); + let mut decoder = KeyDecoder::new(); + decoder.push(0xc3).unwrap(); + assert!(decoder.finish_pending().is_err()); + assert!(decoder.push(b' ').is_err()); +} + +#[test] +fn ambiguous_alt_space_does_not_escape_control_sequence_parsing() { + let mut decoder = KeyDecoder::new(); + assert!(decoder.push(27).unwrap().is_none()); + assert!(decoder.push(b' ').unwrap().is_none()); + assert!(decoder.finish_pending().is_err()); + assert!(decoder.push(b' ').is_err()); + let mut decoder = KeyDecoder::new(); + decoder.push(27).unwrap(); + let event = decoder.push(b'a').unwrap().unwrap(); + assert_eq!(event.key, Key::Character('a')); + assert!(event.modifiers.alt); +} From 3c234fa58e945f97db967e229dc51ffab2ad0e30 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:18:42 -0700 Subject: [PATCH 3/5] feat(terminal): add optional diagnostic foreground formatting --- .github/workflows/ci.yml | 1 + Cargo.toml | 1 + docs/terminal-input.md | 20 +++++- src/lib.rs | 4 ++ src/terminal_style.rs | 116 ++++++++++++++++++++++++++++++++ tests/terminal_input_windows.rs | 53 +++++++++++++++ tests/terminal_style.rs | 33 +++++++++ 7 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/terminal_style.rs create mode 100644 tests/terminal_style.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d66ed5af..f0ecab08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,7 @@ jobs: - hash-sha256 - secure-random - text-similarity + - terminal-style - event-stream - http-server - http-client diff --git a/Cargo.toml b/Cargo.toml index 7059a786..aba683aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ default = [] # Bounded OS entropy, independent of crash/profiling facilities. secure-random = ["dep:getrandom"] text-similarity = ["dep:strsim"] +terminal-style = [] # Advisory locking and modification-time setting are implemented natively # per host (see `src/platform_linux/fs.rs`, `src/platform_macos/fs.rs`, # `src/platform_win/fs.rs`) rather than through a wrapper crate, matching the diff --git a/docs/terminal-input.md b/docs/terminal-input.md index beafa351..ff039094 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -63,6 +63,24 @@ may consume at most 64 KiB including its introducer and terminator, without buffering the body. Invalid UTF-8, malformed or oversized sequences poison the decoder: no trailing spaces are reinterpreted as keys after an error. -Issue #178 still requires decoded-key native validation, styling, FastLED policy +## Diagnostic formatting + +The independent `terminal-style` feature adds no dependency. It provides a +borrowed `StyledText` formatter with a facade-owned 16-color foreground palette. +The caller chooses the color and whether it is enabled. The formatter neither +reads environment variables nor detects terminals. Disabled output preserves +plain text; enabled output wraps it in ANSI foreground selection and default +foreground reset. It preserves embedded escapes and is not a sanitizer. Writer +errors propagate; a reset cannot be guaranteed after an output failure. + +`prepare_stderr_ansi` enables Windows console ANSI processing while preserving +other mode flags. It returns false for redirected/non-console stderr or consoles +that reject VT support, and errors for other native failures. Preparation is +idempotent but persistent: writers sharing the console buffer can observe the +mode change. On Unix it needs no native action and returns true, which is not +a TTY or terminal-capability assertion. Applications own `NO_COLOR`, `TERM`, +redirection, fallback and diagnostic-message policy. + +Issue #178 still requires decoded-key/style native validation, FastLED policy adoption, release and exact published-version consumption. This does not yet complete the Crossterm migration. diff --git a/src/lib.rs b/src/lib.rs index 747e0df6..002c5c11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,10 @@ pub mod text; #[cfg(feature = "pty")] pub mod keys; +/// Allocation-free diagnostic styling and native ANSI output preparation. +#[cfg(feature = "terminal-style")] +pub mod terminal_style; + /// Bounded extraction into caller-exclusive staging directories. #[cfg(feature = "archive")] pub mod archive; diff --git a/src/terminal_style.rs b/src/terminal_style.rs new file mode 100644 index 00000000..d83f2105 --- /dev/null +++ b/src/terminal_style.rs @@ -0,0 +1,116 @@ +//! Foreground formatting without environment, color-choice or warning policy. +//! Callers choose whether to emit colors. Text is preserved verbatim, including +//! embedded escapes; this formatter is not a terminal-output sanitizer. + +use std::{fmt, io}; + +/// Named colors in the ANSI 16-color palette (bright names match diagnostics). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Foreground { + Black = 0, + DarkRed = 1, + DarkGreen = 2, + DarkYellow = 3, + DarkBlue = 4, + DarkMagenta = 5, + DarkCyan = 6, + Grey = 7, + DarkGrey = 8, + Red = 9, + Green = 10, + Yellow = 11, + Blue = 12, + Magenta = 13, + Cyan = 14, + White = 15, +} + +/// Borrowed, allocation-free text wrapper. Successful colored formatting +/// restores the default foreground, not a previously active foreground. If the +/// writer fails, its error is propagated; a reset cannot then be guaranteed. +#[derive(Clone, Copy, Debug)] +pub struct StyledText<'a> { + text: &'a str, + foreground: Foreground, + enabled: bool, +} + +impl<'a> StyledText<'a> { + pub fn new(text: &'a str, foreground: Foreground, enabled: bool) -> Self { + Self { + text, + foreground, + enabled, + } + } +} + +impl fmt::Display for StyledText<'_> { + fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.enabled { + write!(output, "\x1b[38;5;{}m", self.foreground as u8)?; + } + output.write_str(self.text)?; + if self.enabled { + output.write_str("\x1b[39m")?; + } + Ok(()) + } +} + +/// Prepare stderr's native console to interpret ANSI output, without deciding +/// color policy. Unix needs no preparation and returns true; this is not a TTY +/// or TERM probe. Windows returns false for redirected/non-console handles or +/// consoles without VT support, and errors for other native failures. +/// +/// Windows mode changes last for the attached console buffer's lifetime and +/// can affect other writers sharing that buffer. Existing mode bits are kept. +/// Applications should prepare once, then decide how to handle NO_COLOR, +/// redirected output, TERM, and native errors themselves. +pub fn prepare_stderr_ansi() -> io::Result { + #[cfg(not(windows))] + { + Ok(true) + } + #[cfg(windows)] + { + use winapi::shared::winerror::{ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER}; + use winapi::um::consoleapi::{GetConsoleMode, SetConsoleMode}; + use winapi::um::handleapi::INVALID_HANDLE_VALUE; + use winapi::um::processenv::GetStdHandle; + use winapi::um::winbase::STD_ERROR_HANDLE; + use winapi::um::wincon::{ENABLE_PROCESSED_OUTPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING}; + // SAFETY: GetStdHandle has no pointer arguments; the borrowed handle is + // used only for mode calls and is never closed by this capability. + let handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::new( + io::ErrorKind::NotConnected, + "stderr has no native handle", + )); + } + let mut mode = 0; + // SAFETY: mode is writable; native code validates the borrowed handle. + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(ERROR_INVALID_HANDLE as i32) { + Ok(false) + } else { + Err(error) + }; + } + let enabled = mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + // SAFETY: the live console handle and documented output flags came from + // the successful mode query above. Other existing bits are preserved. + if mode != enabled && unsafe { SetConsoleMode(handle, enabled) } == 0 { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + Ok(false) + } else { + Err(error) + }; + } + Ok(true) + } +} diff --git a/tests/terminal_input_windows.rs b/tests/terminal_input_windows.rs index 58120223..571ab4a5 100644 --- a/tests/terminal_input_windows.rs +++ b/tests/terminal_input_windows.rs @@ -25,6 +25,8 @@ fn native_console_session_restores_mode_and_excludes_overlap() { "{}", io::Error::last_os_error() ); + #[cfg(feature = "terminal-style")] + verify_stderr_style_preparation(); let name: Vec = "CONIN$\0".encode_utf16().collect(); // SAFETY: name is terminated; null security/template request defaults. let input = unsafe { @@ -97,3 +99,54 @@ fn native_console_session_restores_mode_and_excludes_overlap() { String::from_utf8_lossy(&output.stderr) ); } + +#[cfg(feature = "terminal-style")] +fn verify_stderr_style_preparation() { + use winapi::um::consoleapi::GetConsoleMode; + use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING}; + use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE}; + use winapi::um::processenv::{GetStdHandle, SetStdHandle}; + use winapi::um::winbase::STD_ERROR_HANDLE; + use winapi::um::wincon::{ENABLE_PROCESSED_OUTPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING}; + use winapi::um::winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE}; + + let name: Vec = "CONOUT$\0".encode_utf16().collect(); + // SAFETY: terminated name, default optional pointers, owned child console. + let output = unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null_mut(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + }; + assert_ne!(output, INVALID_HANDLE_VALUE); + // SAFETY: retrieves the child process's current stderr handle without ownership. + let previous = unsafe { GetStdHandle(STD_ERROR_HANDLE) }; + // SAFETY: output remains live until after previous stderr is restored. + assert_ne!(unsafe { SetStdHandle(STD_ERROR_HANDLE, output) }, 0); + let mut before = 0; + let mut after = 0; + // SAFETY: valid live output handle and writable mode output. + let queried_before = unsafe { GetConsoleMode(output, &mut before) }; + let first = kernal_api::terminal_style::prepare_stderr_ansi(); + let second = kernal_api::terminal_style::prepare_stderr_ansi(); + // SAFETY: same live console output handle and writable output. + let queried_after = unsafe { GetConsoleMode(output, &mut after) }; + // Restore captured diagnostic output before any subsequent assertions. + // SAFETY: previous is still owned by the child process runtime. + assert_ne!(unsafe { SetStdHandle(STD_ERROR_HANDLE, previous) }, 0); + // SAFETY: no operation retains output after restoration. + assert_ne!(unsafe { CloseHandle(output) }, 0); + assert_ne!(queried_before, 0); + assert_ne!(queried_after, 0); + assert!(first.unwrap()); + assert!(second.unwrap()); + assert_eq!( + after, + before | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING + ); +} diff --git a/tests/terminal_style.rs b/tests/terminal_style.rs new file mode 100644 index 00000000..64fe318c --- /dev/null +++ b/tests/terminal_style.rs @@ -0,0 +1,33 @@ +#![cfg(feature = "terminal-style")] + +use kernal_api::terminal_style::{Foreground, StyledText}; + +#[test] +fn yellow_diagnostics_preserve_text_and_reset_only_foreground() { + let text = "warning: déjà vu\nnext line"; + assert_eq!( + StyledText::new(text, Foreground::Yellow, true).to_string(), + "\x1b[38;5;11mwarning: déjà vu\nnext line\x1b[39m" + ); + assert_eq!( + StyledText::new(text, Foreground::Yellow, false).to_string(), + text + ); +} + +#[test] +fn styling_propagates_output_errors() { + use std::fmt::Write; + struct Failed; + impl Write for Failed { + fn write_str(&mut self, _: &str) -> std::fmt::Result { + Err(std::fmt::Error) + } + } + assert!(write!( + Failed, + "{}", + StyledText::new("warning", Foreground::Yellow, true) + ) + .is_err()); +} From bf79313ff08d94bdb40f5227ea149b4ca2dc2f3b Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:25:47 -0700 Subject: [PATCH 4/5] fix(terminal): preserve signals and output during key polling --- docs/terminal-input.md | 13 +++++++++---- src/keys.rs | 9 +++++---- src/platform_linux/terminal.rs | 21 ++++++++++++++++++++- src/platform_macos/terminal.rs | 18 +++++++++++++++++- src/platform_win/terminal.rs | 6 ++++++ src/platform_win/terminal_input.rs | 14 +++++++++++++- tests/terminal_input_ownership.rs | 14 ++++++++++++++ tests/terminal_input_windows.rs | 9 +++++++++ 8 files changed, 93 insertions(+), 11 deletions(-) diff --git a/docs/terminal-input.md b/docs/terminal-input.md index ff039094..9f15ba2c 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -30,13 +30,18 @@ pasted input can contain those bytes. Use the decoded API below for key policy. ## Decoded keys -`keys::TerminalKeys` owns the existing raw capture session and returns one +`keys::TerminalKeys` reuses native capture in noncanonical, no-echo mode and returns one facade-owned `KeyEvent` per `poll`. Calls accept a requested wait of at most 100 ms and examine at most 64 KiB of bytes. Remaining queued bytes survive subsequent calls, including zero-wait calls. Drop uses the native session's -best-effort restoration. Raw capture changes signal-key behavior: consumers -must handle the decoded control-C event rather than rely on a cooked-terminal -signal. Opening still has the platform-specific non-terminal behavior above. +best-effort restoration. Existing signal-key behavior is preserved (Unix ISIG +and Windows processed input), as are Unix input translations and output flags. +With normal initial settings Ctrl+C therefore remains a native signal, not a +decoded event. Applications must register graceful signal handling before +opening capture so interruption can drop the owner and restore modes; abrupt +process termination cannot run Rust Drop. Opening still has the platform-specific +non-terminal behavior above. The raw `TerminalInputSession::new` API used for +PTY forwarding is unchanged. `keys::KeyDecoder` is the same incremental decoder without native ownership; callers feeding bytes directly own its timing and use `finish_pending` after diff --git a/src/keys.rs b/src/keys.rs index 5c34d8a3..6f7b2f48 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -228,9 +228,10 @@ impl KeyDecoder { } } -/// Exclusive raw input owner. Drop restores modes best-effort via the existing -/// native session. Raw capture changes signal-key behavior: the application -/// must handle control-C events. No background listener or channel is added. +/// Exclusive noncanonical, no-echo input owner. Existing native signal-key and +/// output processing are preserved; normally Ctrl+C remains a signal, not an +/// input event. Drop restores modes best-effort via the existing native session. +/// No additional background listener or channel is added. pub struct TerminalKeys { input: crate::TerminalInputSession, decoder: KeyDecoder, @@ -241,7 +242,7 @@ pub struct TerminalKeys { impl TerminalKeys { pub fn new() -> io::Result> { - crate::TerminalInputSession::new().map(|input| { + crate::TerminalInputSession::new_for_keys().map(|input| { input.map(|input| Self { input, decoder: KeyDecoder::new(), diff --git a/src/platform_linux/terminal.rs b/src/platform_linux/terminal.rs index 79d91004..9b744586 100644 --- a/src/platform_linux/terminal.rs +++ b/src/platform_linux/terminal.rs @@ -397,6 +397,14 @@ pub struct TerminalInputSession { #[cfg(feature = "pty")] impl TerminalInputSession { pub fn new() -> std::io::Result> { + Self::new_with_mode(true) + } + + pub(crate) fn new_for_keys() -> std::io::Result> { + Self::new_with_mode(false) + } + + fn new_with_mode(raw: bool) -> std::io::Result> { let stdin_fd = libc::STDIN_FILENO; if unsafe { libc::isatty(stdin_fd) } != 1 { return Ok(None); @@ -408,7 +416,18 @@ impl TerminalInputSession { } let original_mode = unsafe { original_mode.assume_init() }; let mut raw_mode = original_mode; - unsafe { libc::cfmakeraw(&mut raw_mode) }; + if raw { + // SAFETY: raw_mode is an initialized writable termios value. + unsafe { libc::cfmakeraw(&mut raw_mode) }; + } else { + // Key polling is noncanonical/no-echo but preserves signal keys, + // input translations and output processing from the saved mode. + raw_mode.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ECHONL); + // A signal can flush input between poll and read. Never block the + // post-poll key read waiting for replacement bytes. + raw_mode.c_cc[libc::VMIN] = 0; + raw_mode.c_cc[libc::VTIME] = 0; + } if unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw_mode) } != 0 { return Err(std::io::Error::last_os_error()); } diff --git a/src/platform_macos/terminal.rs b/src/platform_macos/terminal.rs index c207eab1..e601ef9b 100644 --- a/src/platform_macos/terminal.rs +++ b/src/platform_macos/terminal.rs @@ -351,6 +351,14 @@ pub struct TerminalInputSession { #[cfg(feature = "pty")] impl TerminalInputSession { pub fn new() -> std::io::Result> { + Self::new_with_mode(true) + } + + pub(crate) fn new_for_keys() -> std::io::Result> { + Self::new_with_mode(false) + } + + fn new_with_mode(raw: bool) -> std::io::Result> { let stdin_fd = libc::STDIN_FILENO; if unsafe { libc::isatty(stdin_fd) } != 1 { return Ok(None); } let input_lease = crate::platform::terminal::InputLease::acquire()?; @@ -358,7 +366,15 @@ impl TerminalInputSession { if unsafe { libc::tcgetattr(stdin_fd, original_mode.as_mut_ptr()) } != 0 { return Err(std::io::Error::last_os_error()); } let original_mode = unsafe { original_mode.assume_init() }; let mut raw_mode = original_mode; - unsafe { libc::cfmakeraw(&mut raw_mode) }; + if raw { + // SAFETY: raw_mode is an initialized writable termios value. + unsafe { libc::cfmakeraw(&mut raw_mode) }; + } else { + raw_mode.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ECHONL); + // Signals can invalidate readiness by flushing the input queue. + raw_mode.c_cc[libc::VMIN] = 0; + raw_mode.c_cc[libc::VTIME] = 0; + } if unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw_mode) } != 0 { return Err(std::io::Error::last_os_error()); } Ok(Some(Self { stdin_fd, original_mode, _input_lease: input_lease })) } diff --git a/src/platform_win/terminal.rs b/src/platform_win/terminal.rs index 8ac5d6e1..5c5f896d 100644 --- a/src/platform_win/terminal.rs +++ b/src/platform_win/terminal.rs @@ -337,6 +337,12 @@ pub struct TerminalInputSession(super::terminal_input::TerminalInputCore); #[cfg(feature = "pty")] impl TerminalInputSession { + pub(crate) fn new_for_keys() -> std::io::Result> { + let input = super::terminal_input::TerminalInputCore::new(); + input.start_for_keys()?; + Ok(Some(Self(input))) + } + pub fn new() -> std::io::Result> { let input = super::terminal_input::TerminalInputCore::new(); input.start_impl()?; diff --git a/src/platform_win/terminal_input.rs b/src/platform_win/terminal_input.rs index 646dde87..e1646ef0 100644 --- a/src/platform_win/terminal_input.rs +++ b/src/platform_win/terminal_input.rs @@ -867,6 +867,15 @@ impl TerminalInputCore { #[cfg(windows)] /// Starts native terminal input capture for the attached Windows console. pub fn start_impl(&self) -> Result<(), std::io::Error> { + self.start_with_signal_keys(false) + } + + #[cfg(feature = "pty")] + pub(crate) fn start_for_keys(&self) -> Result<(), std::io::Error> { + self.start_with_signal_keys(true) + } + + fn start_with_signal_keys(&self, preserve_signal_keys: bool) -> Result<(), std::io::Error> { use winapi::um::consoleapi::{GetConsoleMode, SetConsoleMode}; use winapi::um::handleapi::INVALID_HANDLE_VALUE; use winapi::um::processenv::GetStdHandle; @@ -895,7 +904,10 @@ impl TerminalInputCore { )); } - let active_mode = native_terminal_input_mode(original_mode); + let mut active_mode = native_terminal_input_mode(original_mode); + if preserve_signal_keys { + active_mode |= original_mode & winapi::um::wincon::ENABLE_PROCESSED_INPUT; + } let set_mode = unsafe { SetConsoleMode(input_handle, active_mode) }; if set_mode == 0 { return Err(std::io::Error::last_os_error()); diff --git a/tests/terminal_input_ownership.rs b/tests/terminal_input_ownership.rs index 83c7dbf5..59145c5d 100644 --- a/tests/terminal_input_ownership.rs +++ b/tests/terminal_input_ownership.rs @@ -41,6 +41,16 @@ fn native_session_rejects_overlap_and_restores_mode() { assert_eq!(before.c_cc, after.c_cc); drop(kernal_api::TerminalInputSession::new().unwrap().unwrap()); let mut keys = kernal_api::keys::TerminalKeys::new().unwrap().unwrap(); + let key_mode = stdin_mode(); + assert_eq!(before.c_lflag & libc::ISIG, key_mode.c_lflag & libc::ISIG); + assert_eq!(before.c_oflag, key_mode.c_oflag); + assert_eq!(before.c_iflag, key_mode.c_iflag); + assert_eq!(key_mode.c_cc[libc::VMIN], 0); + assert_eq!(key_mode.c_cc[libc::VTIME], 0); + assert_eq!( + key_mode.c_lflag & (libc::ICANON | libc::ECHO | libc::ECHONL), + 0 + ); use kernal_api::keys::Key; for expected in [ Key::Other, @@ -51,6 +61,10 @@ fn native_session_rejects_overlap_and_restores_mode() { assert_eq!(keys.poll(Duration::ZERO).unwrap().unwrap().key, expected); } assert!(keys.poll(Duration::ZERO).unwrap().is_none()); + let mut empty = [0u8; 1]; + // SAFETY: stdin is this child's live PTY and empty is writable. This + // read must return zero without poll, modeling readiness invalidation. + assert_eq!(unsafe { libc::read(0, empty.as_mut_ptr().cast(), 1) }, 0); assert_eq!( keys.poll(Duration::from_secs(1)).unwrap_err().kind(), io::ErrorKind::InvalidInput diff --git a/tests/terminal_input_windows.rs b/tests/terminal_input_windows.rs index 571ab4a5..c2ad1ecc 100644 --- a/tests/terminal_input_windows.rs +++ b/tests/terminal_input_windows.rs @@ -63,6 +63,15 @@ fn native_console_session_restores_mode_and_excludes_overlap() { assert_eq!(before, mode()); drop(kernal_api::TerminalInputSession::new().unwrap().unwrap()); assert_eq!(before, mode()); + let keys = kernal_api::keys::TerminalKeys::new().unwrap().unwrap(); + use winapi::um::wincon::{ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT}; + assert_eq!( + before & ENABLE_PROCESSED_INPUT, + mode() & ENABLE_PROCESSED_INPUT + ); + assert_eq!(mode() & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT), 0); + drop(keys); + assert_eq!(before, mode()); // SAFETY: all capture workers have joined before this owned handle closes. assert_ne!(unsafe { CloseHandle(input) }, 0); // SAFETY: releases only this child's console attachment. From b9d75b00a367e57a7aa10aff57fff1afc22b2445 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:40:24 -0700 Subject: [PATCH 5/5] feat: isolate terminal input from PTY spawning (refs #178) --- .github/workflows/ci.yml | 1 + Cargo.toml | 4 +++- ci/check_compilation_boundary_dependencies.py | 3 +++ docs/terminal-input.md | 6 ++++-- src/lib.rs | 7 +++++-- src/platform/terminal.rs | 11 +++++++---- src/platform_linux/terminal.rs | 8 ++++---- src/platform_macos/terminal.rs | 8 ++++---- src/platform_win/terminal.rs | 8 ++++---- src/platform_win/terminal_input.rs | 2 +- tests/terminal_input_ownership.rs | 2 +- tests/terminal_input_windows.rs | 2 +- tests/terminal_keys.rs | 2 +- 13 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ecab08..889df851 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,6 +164,7 @@ jobs: - secure-random - text-similarity - terminal-style + - terminal-input - event-stream - http-server - http-client diff --git a/Cargo.toml b/Cargo.toml index aba683aa..c8e2fb75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ default = [] secure-random = ["dep:getrandom"] text-similarity = ["dep:strsim"] terminal-style = [] +# Native terminal capture and key decoding without PTY process spawning. +terminal-input = [] # Advisory locking and modification-time setting are implemented natively # per host (see `src/platform_linux/fs.rs`, `src/platform_macos/fs.rs`, # `src/platform_win/fs.rs`) rather than through a wrapper crate, matching the @@ -71,7 +73,7 @@ daemon-registration = ["running-process/daemon-registration"] # separate from v1 records and pulls no broker client, identity, IPC, or # runtime policy into applications that dual-write during the v1-to-v2 rollout. daemon-registration-v2 = ["running-process/daemon-registration-v2"] -pty = ["dep:portable-pty"] +pty = ["dep:portable-pty", "terminal-input"] event-stream = ["dep:futures-core", "dep:tokio-stream", "tokio-stream/sync"] http-server = ["dep:hyper", "hyper/server", "hyper/http1", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "dep:futures-core"] # Window-icon and stock-icon mechanics for the host console or a child. This diff --git a/ci/check_compilation_boundary_dependencies.py b/ci/check_compilation_boundary_dependencies.py index 53820f72..928f20ad 100644 --- a/ci/check_compilation_boundary_dependencies.py +++ b/ci/check_compilation_boundary_dependencies.py @@ -12,6 +12,7 @@ import sys CASES = ( + ("pty", "portable-pty"), ("text-similarity", "strsim"), ("wasm-sketch-host", "wasmtime"), ("ipc", "interprocess"), @@ -74,6 +75,8 @@ def main() -> int: unexpected = sorted(graph & SKETCH_AND_WEBVIEW_PACKAGES) if unexpected: failures.append(f"{label} graph unexpectedly contains {', '.join(unexpected)}") + if "portable-pty" in tree("terminal-input"): + failures.append("terminal-input unexpectedly enables PTY process spawning") enabled_graphs: dict[str, set[str]] = {} # getrandom already occurs transitively in the host substrate. Prove this # capability activates the backend without importing unrelated facilities; diff --git a/docs/terminal-input.md b/docs/terminal-input.md index 9f15ba2c..1de52f90 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -1,7 +1,9 @@ # Native terminal input groundwork -The `pty` feature exposes `TerminalInputSession`, an owned raw-terminal capture -session. `new` snapshots the input mode and Drop attempts to restore it. Unix +The dependency-free `terminal-input` feature exposes key decoding and +`TerminalInputSession`, an owned raw-terminal capture session. The `pty` feature +includes it and additionally enables PTY process spawning through a private +backend. `new` snapshots the input mode and Drop attempts to restore it. Unix returns `Ok(None)` for non-terminal stdin; Windows currently reports an error when stdin is not an attached console. Callers must not assume parity yet. diff --git a/src/lib.rs b/src/lib.rs index 002c5c11..470c6257 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,7 +31,7 @@ pub mod random; pub mod text; /// Bounded terminal key decoding and owned native polling. -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] pub mod keys; /// Allocation-free diagnostic styling and native ANSI output preparation. @@ -392,9 +392,12 @@ pub use platform_imp::terminal::{ input_payload, is_ignorable_process_control_error, prepare_unmanaged_pty_child, query_responses, resize_pty, shell_argv, signal_pty_tree, terminate_pty_child, wait_before_pty_close_supported, Backend, ChildProcessInfo, ConPtyBackendKind, - OrphanConhostInfo, PtyProcessGuard, PtySpawnContext, TerminalInputSession, + OrphanConhostInfo, PtyProcessGuard, PtySpawnContext, }; +#[cfg(feature = "terminal-input")] +pub use platform_imp::terminal::TerminalInputSession; + #[cfg(feature = "session-relay")] pub use platform_imp::relay_local_socket_session; diff --git a/src/platform/terminal.rs b/src/platform/terminal.rs index 2647e94c..e0d2dc52 100644 --- a/src/platform/terminal.rs +++ b/src/platform/terminal.rs @@ -7,13 +7,13 @@ use std::sync::{Arc, Mutex}; static INPUT_OWNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -#[cfg(any(windows, all(test, feature = "pty")))] +#[cfg(any(windows, all(test, feature = "terminal-input")))] pub(crate) struct InputQueue { events: std::collections::VecDeque<(T, usize)>, bytes: usize, } -#[cfg(any(windows, all(test, feature = "pty")))] +#[cfg(any(windows, all(test, feature = "terminal-input")))] impl InputQueue { pub(crate) fn new() -> Self { Self { @@ -238,9 +238,12 @@ pub mod input { #[cfg(feature = "pty")] pub use crate::{ Backend, ChildProcessInfo, ConPtyBackendKind, OrphanConhostInfo, PtyProcessGuard, - PtySpawnContext, TerminalInputSession, + PtySpawnContext, }; +#[cfg(feature = "terminal-input")] +pub use crate::TerminalInputSession; + #[cfg(feature = "pty")] pub use crate::current_backend_kind; @@ -326,7 +329,7 @@ pub fn find_orphan_conhosts() -> Vec { crate::find_orphan_conhosts() } -#[cfg(all(test, feature = "pty"))] +#[cfg(all(test, feature = "terminal-input"))] mod ownership_tests { #[test] fn capture_queue_enforces_event_and_byte_limits() { diff --git a/src/platform_linux/terminal.rs b/src/platform_linux/terminal.rs index 9b744586..509160f1 100644 --- a/src/platform_linux/terminal.rs +++ b/src/platform_linux/terminal.rs @@ -193,7 +193,7 @@ pub use pty::*; #[cfg(feature = "pty")] use crate::platform::process::UnixSignalKind; -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] use crate::platform::terminal::PtyInputChunk; #[cfg(feature = "pty")] @@ -387,14 +387,14 @@ pub fn find_orphan_conhosts() -> Vec { Vec::new() } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] pub struct TerminalInputSession { stdin_fd: i32, original_mode: libc::termios, _input_lease: crate::platform::terminal::InputLease, } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] impl TerminalInputSession { pub fn new() -> std::io::Result> { Self::new_with_mode(true) @@ -470,7 +470,7 @@ impl TerminalInputSession { } } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] impl Drop for TerminalInputSession { fn drop(&mut self) { unsafe { diff --git a/src/platform_macos/terminal.rs b/src/platform_macos/terminal.rs index e601ef9b..6d24cc89 100644 --- a/src/platform_macos/terminal.rs +++ b/src/platform_macos/terminal.rs @@ -193,7 +193,7 @@ pub use pty::*; #[cfg(feature = "pty")] use crate::platform::process::UnixSignalKind; -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] use crate::platform::terminal::PtyInputChunk; #[cfg(feature = "pty")] @@ -341,14 +341,14 @@ pub fn find_child_processes(_parent_pid: u32) -> Vec { Vec::ne #[cfg(feature = "pty")] pub fn find_orphan_conhosts() -> Vec { Vec::new() } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] pub struct TerminalInputSession { stdin_fd: i32, original_mode: libc::termios, _input_lease: crate::platform::terminal::InputLease, } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] impl TerminalInputSession { pub fn new() -> std::io::Result> { Self::new_with_mode(true) @@ -396,7 +396,7 @@ impl TerminalInputSession { } } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] impl Drop for TerminalInputSession { fn drop(&mut self) { unsafe { libc::tcsetattr(self.stdin_fd, libc::TCSANOW, &self.original_mode); } } } diff --git a/src/platform_win/terminal.rs b/src/platform_win/terminal.rs index 5c5f896d..c9a14e15 100644 --- a/src/platform_win/terminal.rs +++ b/src/platform_win/terminal.rs @@ -123,7 +123,7 @@ impl PtyChild for conpty_passthrough::child::ConPtyChild { #[cfg(feature = "pty")] pub type Backend = ConPtyBackend; -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] use crate::platform::terminal::PtyInputChunk; #[cfg(feature = "pty")] @@ -332,10 +332,10 @@ pub fn resize_pty( _size: crate::platform::terminal::PtySize, ) -> std::io::Result<()> { Ok(()) } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] pub struct TerminalInputSession(super::terminal_input::TerminalInputCore); -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] impl TerminalInputSession { pub(crate) fn new_for_keys() -> std::io::Result> { let input = super::terminal_input::TerminalInputCore::new(); @@ -360,7 +360,7 @@ impl TerminalInputSession { } } -#[cfg(feature = "pty")] +#[cfg(feature = "terminal-input")] impl Drop for TerminalInputSession { fn drop(&mut self) { let _ = self.0.stop_impl(); } } diff --git a/src/platform_win/terminal_input.rs b/src/platform_win/terminal_input.rs index e1646ef0..fc3c35e3 100644 --- a/src/platform_win/terminal_input.rs +++ b/src/platform_win/terminal_input.rs @@ -870,7 +870,7 @@ impl TerminalInputCore { self.start_with_signal_keys(false) } - #[cfg(feature = "pty")] + #[cfg(feature = "terminal-input")] pub(crate) fn start_for_keys(&self) -> Result<(), std::io::Error> { self.start_with_signal_keys(true) } diff --git a/tests/terminal_input_ownership.rs b/tests/terminal_input_ownership.rs index 59145c5d..3f70d9c8 100644 --- a/tests/terminal_input_ownership.rs +++ b/tests/terminal_input_ownership.rs @@ -1,4 +1,4 @@ -#![cfg(all(unix, feature = "pty"))] +#![cfg(all(unix, feature = "terminal-input"))] use std::io; use std::io::Write; diff --git a/tests/terminal_input_windows.rs b/tests/terminal_input_windows.rs index c2ad1ecc..e17caabe 100644 --- a/tests/terminal_input_windows.rs +++ b/tests/terminal_input_windows.rs @@ -1,4 +1,4 @@ -#![cfg(all(windows, feature = "pty"))] +#![cfg(all(windows, feature = "terminal-input"))] use std::io; use std::process::{Command, Stdio}; diff --git a/tests/terminal_keys.rs b/tests/terminal_keys.rs index e8f273ac..4d045c87 100644 --- a/tests/terminal_keys.rs +++ b/tests/terminal_keys.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "pty")] +#![cfg(feature = "terminal-input")] use kernal_api::keys::{Key, KeyDecoder, KeyModifiers};