Skip to main content

tern_sdk/
term.rs

1//! The terminal a session talks through.
2//!
3//! [`Terminal`] lets tests and custom transports drive a session over
4//! in-memory pipes; [`Tty`] is the process's own stdin/stdout in raw mode
5//! (`poll(2)` on Unix, a console wait on Windows). `Tty` and tty detection
6//! are available only on Unix and Windows; [`Terminal`] is portable.
7
8#[cfg(any(unix, windows))]
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::{io, time::Duration};
11
12/// Process-global terminal ownership, acquired before touching platform state.
13#[cfg(any(unix, windows))]
14static OWNED: AtomicBool = AtomicBool::new(false);
15
16/// Releases exclusive tty ownership after restoration (also on setup failure).
17#[cfg(any(unix, windows))]
18#[derive(Debug)]
19struct Ownership;
20
21#[cfg(any(unix, windows))]
22impl Ownership {
23	fn acquire() -> io::Result<Self> {
24		OWNED
25			.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
26			.map(|_| Self)
27			.map_err(|_| {
28				io::Error::new(io::ErrorKind::AlreadyExists, "the process tty is already in use")
29			})
30	}
31}
32
33#[cfg(any(unix, windows))]
34impl Drop for Ownership {
35	fn drop(&mut self) {
36		OWNED.store(false, Ordering::Release);
37	}
38}
39
40/// A byte stream to a terminal with a timed read.
41pub trait Terminal {
42	/// Writes all of `bytes` and flushes.
43	///
44	/// # Errors
45	/// When the output fails.
46	fn write(&mut self, bytes: &[u8]) -> io::Result<()>;
47
48	/// Reads available input into `buf`, waiting at most `timeout` (`None`
49	/// waits for input). `Ok(None)` means the wait timed out; `Ok(Some(0))`
50	/// that the input is closed.
51	///
52	/// # Errors
53	/// When the input fails.
54	fn read(&mut self, buf: &mut [u8], timeout: Option<Duration>) -> io::Result<Option<usize>>;
55
56	/// Puts the terminal back as it was (leaves raw mode). Called once, when
57	/// the session closes or fails to connect.
58	///
59	/// # Errors
60	/// When the terminal can't be restored.
61	fn restore(&mut self) -> io::Result<()> {
62		Ok(())
63	}
64
65	/// Notes which input modes the session enabled, so a process killed by a
66	/// signal can still undo them.
67	fn modes(&mut self, _paste: bool, _kitty: bool) {}
68}
69
70/// Whether stdin and stdout are both terminals.
71#[cfg(any(unix, windows))]
72pub fn is_tty() -> bool {
73	sys::is_tty()
74}
75
76/// Whether stdout is a terminal.
77#[cfg(any(unix, windows))]
78pub fn stdout_is_tty() -> bool {
79	sys::stdout_is_tty()
80}
81
82/// The width of the terminal on stdout in columns, when it is one.
83#[cfg(any(unix, windows))]
84pub fn columns() -> Option<u16> {
85	sys::columns()
86}
87
88/// The process's own terminal (stdin and stdout), in raw mode while it
89/// lives.
90#[cfg(any(unix, windows))]
91#[derive(Debug)]
92pub struct Tty {
93	/// The platform state.
94	inner:     sys::Raw,
95	/// Restored already.
96	restored:  bool,
97	/// Held until restoration finishes.
98	ownership: Option<Ownership>,
99}
100
101#[cfg(any(unix, windows))]
102impl Tty {
103	/// Switches the terminal to raw input: no echo, no line editing, no
104	/// signals from keys (Ctrl+C is a key). Output processing stays on.
105	///
106	/// # Errors
107	/// When stdin is not a terminal or its mode can't be changed.
108	pub fn open() -> io::Result<Self> {
109		let ownership = Ownership::acquire()?;
110		Ok(Self { inner: sys::Raw::enter()?, restored: false, ownership: Some(ownership) })
111	}
112}
113
114#[cfg(any(unix, windows))]
115impl Terminal for Tty {
116	fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
117		use io::Write as _;
118		let mut out = io::stdout().lock();
119		out.write_all(bytes)?;
120		out.flush()
121	}
122
123	fn read(&mut self, buf: &mut [u8], timeout: Option<Duration>) -> io::Result<Option<usize>> {
124		self.inner.read(buf, timeout)
125	}
126
127	fn restore(&mut self) -> io::Result<()> {
128		if self.restored {
129			return Ok(());
130		}
131		self.restored = true;
132		let result = self.inner.leave();
133		self.ownership.take();
134		result
135	}
136
137	fn modes(&mut self, paste: bool, kitty: bool) {
138		sys::set_modes(paste, kitty);
139	}
140}
141
142#[cfg(any(unix, windows))]
143impl Drop for Tty {
144	fn drop(&mut self) {
145		if let Err(err) = self.restore() {
146			tracing::warn!(%err, "could not restore the terminal");
147		}
148	}
149}
150
151#[cfg(all(test, any(unix, windows)))]
152mod tests {
153	use super::Ownership;
154
155	#[test]
156	fn tty_ownership_is_exclusive_and_released_by_drop() {
157		let first = Ownership::acquire().expect("first owner");
158		assert!(Ownership::acquire().is_err());
159		drop(first);
160		assert!(Ownership::acquire().is_ok());
161	}
162}
163
164/// The bytes that undo the input modes a session enabled.
165pub(crate) const fn mode_reset(paste: bool, kitty: bool) -> &'static [u8] {
166	match (paste, kitty) {
167		(true, true) => b"\x1b[<u\x1b[?2004l",
168		(false, true) => b"\x1b[<u",
169		(true, false) => b"\x1b[?2004l",
170		(false, false) => b"",
171	}
172}
173
174#[cfg(unix)]
175mod sys {
176	use std::{
177		cell::UnsafeCell,
178		io,
179		mem::MaybeUninit,
180		sync::atomic::{AtomicBool, AtomicU8, Ordering},
181		time::Duration,
182	};
183
184	/// The termios to restore from a signal handler.
185	struct Saved(UnsafeCell<MaybeUninit<libc::termios>>);
186
187	// SAFETY: written once before `ARMED` is set (release) and only read after
188	// `ARMED` is seen set (acquire); never written while armed.
189	unsafe impl Sync for Saved {}
190
191	/// The termios saved by the live [`Raw`].
192	static SAVED: Saved = Saved(UnsafeCell::new(MaybeUninit::uninit()));
193	/// Whether a signal should restore `SAVED`.
194	static ARMED: AtomicBool = AtomicBool::new(false);
195	/// Input modes enabled: bit 0 bracketed paste, bit 1 kitty keys.
196	static MODES: AtomicU8 = AtomicU8::new(0);
197
198	/// The signals that restore the terminal before the process ends.
199	const SIGNALS: [libc::c_int; 4] = [libc::SIGTERM, libc::SIGHUP, libc::SIGINT, libc::SIGQUIT];
200
201	/// Raw mode on stdin, with the previous state to restore.
202	#[derive(Debug)]
203	pub(super) struct Raw {
204		/// The termios before raw mode.
205		saved:    libc::termios,
206		/// The signal dispositions before ours.
207		previous: Vec<(libc::c_int, libc::sigaction)>,
208	}
209
210	pub(super) fn is_tty() -> bool {
211		// SAFETY: isatty only inspects the descriptors.
212		unsafe { libc::isatty(0) == 1 && libc::isatty(1) == 1 }
213	}
214
215	pub(super) fn stdout_is_tty() -> bool {
216		// SAFETY: isatty only inspects the descriptor.
217		unsafe { libc::isatty(1) == 1 }
218	}
219
220	pub(super) fn columns() -> Option<u16> {
221		let mut size = MaybeUninit::<libc::winsize>::zeroed();
222		// SAFETY: TIOCGWINSZ writes a winsize through the pointer.
223		let ok = unsafe { libc::ioctl(1, libc::TIOCGWINSZ, size.as_mut_ptr()) } == 0;
224		// SAFETY: zero-initialized, and filled in when the call succeeded.
225		let size = unsafe { size.assume_init() };
226		(ok && size.ws_col > 0).then_some(size.ws_col)
227	}
228
229	pub(super) fn set_modes(paste: bool, kitty: bool) {
230		MODES.store(u8::from(paste) | (u8::from(kitty) << 1), Ordering::Release);
231	}
232
233	/// Restores the terminal and undoes the modes, then dies of the signal.
234	extern "C" fn on_signal(sig: libc::c_int) {
235		if ARMED.load(Ordering::Acquire) {
236			let modes = MODES.load(Ordering::Acquire);
237			let reset = super::mode_reset(modes & 1 != 0, modes & 2 != 0);
238			// SAFETY: write and tcsetattr are async-signal-safe; `SAVED` was
239			// initialized before `ARMED` was set.
240			unsafe {
241				libc::write(1, reset.as_ptr().cast(), reset.len());
242				libc::tcsetattr(0, libc::TCSANOW, (*SAVED.0.get()).as_ptr());
243			}
244		}
245		// SAFETY: signal and raise are async-signal-safe.
246		unsafe {
247			libc::signal(sig, libc::SIG_DFL);
248			libc::raise(sig);
249		}
250	}
251
252	impl Raw {
253		pub(super) fn enter() -> io::Result<Self> {
254			let mut termios = MaybeUninit::<libc::termios>::uninit();
255			// SAFETY: tcgetattr fills the termios when it succeeds.
256			if unsafe { libc::tcgetattr(0, termios.as_mut_ptr()) } != 0 {
257				return Err(io::Error::last_os_error());
258			}
259			// SAFETY: initialized by the successful tcgetattr.
260			let saved = unsafe { termios.assume_init() };
261			let mut raw = saved;
262			raw.c_iflag &= !(libc::IGNBRK
263				| libc::BRKINT
264				| libc::PARMRK
265				| libc::ISTRIP
266				| libc::INLCR
267				| libc::IGNCR
268				| libc::ICRNL
269				| libc::IXON);
270			raw.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
271			raw.c_cflag &= !(libc::CSIZE | libc::PARENB);
272			raw.c_cflag |= libc::CS8;
273			raw.c_cc[libc::VMIN] = 1;
274			raw.c_cc[libc::VTIME] = 0;
275			// SAFETY: not armed, so no handler reads SAVED while it is written.
276			unsafe { (*SAVED.0.get()).write(saved) };
277			ARMED.store(true, Ordering::Release);
278			let mut previous = Vec::with_capacity(SIGNALS.len());
279			for sig in SIGNALS {
280				// SAFETY: a zeroed sigaction is valid; sigaction fills `old`.
281				unsafe {
282					let mut action: libc::sigaction = std::mem::zeroed();
283					action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as usize;
284					libc::sigemptyset(&mut action.sa_mask);
285					let mut old: libc::sigaction = std::mem::zeroed();
286					if libc::sigaction(sig, &action, &mut old) != 0 {
287						let err = io::Error::last_os_error();
288						let mut state = Self { saved, previous };
289						let _ = state.leave();
290						return Err(err);
291					}
292					previous.push((sig, old));
293				}
294			}
295			let mut state = Self { saved, previous };
296			// SAFETY: restoration is armed before this valid termios is applied.
297			if unsafe { libc::tcsetattr(0, libc::TCSANOW, &raw) } != 0 {
298				let err = io::Error::last_os_error();
299				let _ = state.leave();
300				return Err(err);
301			}
302			Ok(state)
303		}
304
305		#[allow(clippy::unused_self, reason = "the Windows version reads through its handle")]
306		pub(super) fn read(
307			&self,
308			buf: &mut [u8],
309			timeout: Option<Duration>,
310		) -> io::Result<Option<usize>> {
311			let ms = timeout
312				.map_or(-1, |t| libc::c_int::try_from(t.as_millis()).unwrap_or(libc::c_int::MAX));
313			let mut fd = libc::pollfd { fd: 0, events: libc::POLLIN, revents: 0 };
314			// SAFETY: one valid pollfd.
315			let n = unsafe { libc::poll(&mut fd, 1, ms) };
316			if n < 0 {
317				let err = io::Error::last_os_error();
318				return if err.kind() == io::ErrorKind::Interrupted {
319					Ok(None)
320				} else {
321					Err(err)
322				};
323			}
324			if n == 0 {
325				return Ok(None);
326			}
327			// SAFETY: reads at most buf.len() bytes into buf.
328			let got = unsafe { libc::read(0, buf.as_mut_ptr().cast(), buf.len()) };
329			if got < 0 {
330				let err = io::Error::last_os_error();
331				return match err.kind() {
332					io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock => Ok(None),
333					_ => Err(err),
334				};
335			}
336			Ok(Some(got as usize))
337		}
338
339		pub(super) fn leave(&mut self) -> io::Result<()> {
340			// SAFETY: saved is the termios tcgetattr returned.
341			let result = if unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) } == 0 {
342				Ok(())
343			} else {
344				Err(io::Error::last_os_error())
345			};
346			ARMED.store(false, Ordering::Release);
347			MODES.store(0, Ordering::Release);
348			for (sig, old) in self.previous.drain(..) {
349				// SAFETY: restores the disposition sigaction gave us.
350				unsafe { libc::sigaction(sig, &old, std::ptr::null_mut()) };
351			}
352			result
353		}
354	}
355}
356
357#[cfg(windows)]
358mod sys {
359	use std::{io, time::Duration};
360
361	use windows_sys::Win32::{
362		Foundation::{HANDLE, WAIT_OBJECT_0},
363		Storage::FileSystem::ReadFile,
364		System::{
365			Console::{
366				CONSOLE_SCREEN_BUFFER_INFO, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT,
367				ENABLE_PROCESSED_INPUT, ENABLE_PROCESSED_OUTPUT, ENABLE_VIRTUAL_TERMINAL_INPUT,
368				ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, GetConsoleScreenBufferInfo,
369				GetStdHandle, INPUT_RECORD, KEY_EVENT, PeekConsoleInputW, ReadConsoleInputW,
370				STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode,
371			},
372			Threading::{INFINITE, WaitForSingleObject},
373		},
374	};
375
376	/// Raw mode on the console, with the previous modes to restore.
377	#[derive(Debug)]
378	pub(super) struct Raw {
379		/// The console input handle.
380		input:    HANDLE,
381		/// The console output handle.
382		output:   HANDLE,
383		/// The input mode before.
384		in_mode:  u32,
385		/// The output mode before.
386		out_mode: u32,
387	}
388
389	/// The console mode of `handle`, when it is a console.
390	fn mode(handle: HANDLE) -> Option<u32> {
391		let mut mode = 0;
392		// SAFETY: GetConsoleMode writes a u32.
393		(unsafe { GetConsoleMode(handle, &mut mode) } != 0).then_some(mode)
394	}
395
396	pub(super) fn is_tty() -> bool {
397		// SAFETY: GetStdHandle has no preconditions.
398		let (input, output) =
399			unsafe { (GetStdHandle(STD_INPUT_HANDLE), GetStdHandle(STD_OUTPUT_HANDLE)) };
400		mode(input).is_some() && mode(output).is_some()
401	}
402
403	pub(super) fn stdout_is_tty() -> bool {
404		// SAFETY: GetStdHandle has no preconditions.
405		mode(unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }).is_some()
406	}
407
408	pub(super) fn columns() -> Option<u16> {
409		// SAFETY: a zeroed buffer info is valid; the call fills it.
410		unsafe {
411			let mut info: CONSOLE_SCREEN_BUFFER_INFO = std::mem::zeroed();
412			if GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &mut info) == 0 {
413				return None;
414			}
415			u16::try_from(info.srWindow.Right - info.srWindow.Left + 1).ok()
416		}
417	}
418
419	pub(super) const fn set_modes(_paste: bool, _kitty: bool) {}
420
421	impl Raw {
422		pub(super) fn enter() -> io::Result<Self> {
423			// SAFETY: GetStdHandle has no preconditions.
424			let (input, output) =
425				unsafe { (GetStdHandle(STD_INPUT_HANDLE), GetStdHandle(STD_OUTPUT_HANDLE)) };
426			let in_mode = mode(input).ok_or_else(io::Error::last_os_error)?;
427			let out_mode = mode(output).ok_or_else(io::Error::last_os_error)?;
428			let raw_in = (in_mode & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT))
429				| ENABLE_VIRTUAL_TERMINAL_INPUT;
430			let raw_out = out_mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
431			// SAFETY: valid console handles and modes.
432			unsafe {
433				if SetConsoleMode(input, raw_in) == 0 {
434					return Err(io::Error::last_os_error());
435				}
436				if SetConsoleMode(output, raw_out) == 0 {
437					let err = io::Error::last_os_error();
438					SetConsoleMode(input, in_mode);
439					return Err(err);
440				}
441			}
442			Ok(Self { input, output, in_mode, out_mode })
443		}
444
445		/// Whether a key press is queued; other console events are discarded,
446		/// since reading bytes would block on them.
447		fn key_ready(&self) -> io::Result<bool> {
448			loop {
449				let mut record = INPUT_RECORD::default();
450				let mut n = 0;
451				// SAFETY: one record buffer.
452				if unsafe { PeekConsoleInputW(self.input, &mut record, 1, &mut n) } == 0 {
453					return Err(io::Error::last_os_error());
454				}
455				if n == 0 {
456					return Ok(false);
457				}
458				// SAFETY: EventType says which union field is set.
459				let key_down = u32::from(record.EventType) == KEY_EVENT
460					&& unsafe {
461						let key = record.Event.KeyEvent;
462						key.bKeyDown != 0
463							&& (key.uChar.UnicodeChar != 0
464								|| matches!(key.wVirtualKeyCode,
465								0x08 | 0x09 | 0x0d | 0x1b | 0x20..=0x28 | 0x2d | 0x2e | 0x70..=0x87))
466					};
467				if key_down {
468					return Ok(true);
469				}
470				// SAFETY: one record buffer.
471				if unsafe { ReadConsoleInputW(self.input, &mut record, 1, &mut n) } == 0 {
472					return Err(io::Error::last_os_error());
473				}
474			}
475		}
476
477		pub(super) fn read(
478			&self,
479			buf: &mut [u8],
480			timeout: Option<Duration>,
481		) -> io::Result<Option<usize>> {
482			let ms =
483				timeout.map_or(INFINITE, |t| u32::try_from(t.as_millis()).unwrap_or(INFINITE - 1));
484			// SAFETY: a valid handle.
485			if unsafe { WaitForSingleObject(self.input, ms) } != WAIT_OBJECT_0 {
486				return Ok(None);
487			}
488			if !self.key_ready()? {
489				return Ok(None);
490			}
491			let mut got = 0;
492			let len = u32::try_from(buf.len()).unwrap_or(u32::MAX);
493			// SAFETY: reads at most len bytes into buf.
494			if unsafe { ReadFile(self.input, buf.as_mut_ptr(), len, &mut got, std::ptr::null_mut()) }
495				== 0
496			{
497				return Err(io::Error::last_os_error());
498			}
499			Ok(Some(got as usize))
500		}
501
502		pub(super) fn leave(&mut self) -> io::Result<()> {
503			// SAFETY: valid console handles and the modes they had.
504			unsafe {
505				let input = SetConsoleMode(self.input, self.in_mode);
506				let input_error = (input == 0).then(io::Error::last_os_error);
507				let output = SetConsoleMode(self.output, self.out_mode);
508				if let Some(err) = input_error {
509					return Err(err);
510				}
511				if output == 0 {
512					return Err(io::Error::last_os_error());
513				}
514			}
515			Ok(())
516		}
517	}
518}