Skip to main content

uncurses/terminal/
tty.rs

1//! Controlling-terminal helpers.
2//!
3//! `open_tty` opens the controlling terminal directly, useful when
4//! stdio is piped or redirected but the program still needs to talk to
5//! a real terminal. On Unix both halves of the returned pair refer to
6//! `/dev/tty`; on Windows the input is `CONIN$` and the output is
7//! `CONOUT$`.
8//!
9//! The pair is cached process-wide on the first successful call, so
10//! `open_tty` can be called freely from multiple sites without
11//! reopening the device. Both [`TtyInput`] and [`TtyOutput`] are
12//! [`Copy`] handles that reference the shared cache and serialise
13//! concurrent reads / writes through a [`Mutex`].
14//!
15//! The Windows [`TtyOutput`] `Write` impl transparently routes console
16//! writes through `WriteConsoleW` (UTF-16) so non-ASCII text
17//! round-trips correctly through the conpty, matching
18//! [`std::io::Stdout`].
19//!
20//! ## When to use this module
21//!
22//! Use `open_tty` through [`Terminal::open`](super::Terminal::open) when an
23//! application may receive data on stdin or write data to stdout but still
24//! needs to control the user's terminal. Use [`stdin`](super::stdin) and
25//! [`stdout`](super::stdout) instead when inherited stdio is the terminal
26//! interface.
27
28use std::fmt;
29use std::fs::{File, OpenOptions};
30use std::io::{self, Read, Write};
31use std::sync::{Mutex, OnceLock};
32
33#[cfg(unix)]
34use std::os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd};
35
36#[cfg(windows)]
37use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, RawHandle};
38
39// ---------------------------------------------------------------------------
40// Process-wide singletons
41//
42// Both halves of the controlling-terminal pair are cached the first
43// time `open_tty` is called. Subsequent calls return new `Copy`
44// handles that reference the same underlying [`File`] guarded by a
45// [`Mutex`], so concurrent writes from multiple threads are serialised
46// at the byte level. The cached state includes failures: if the
47// initial open fails, every later call surfaces the same error
48// without retrying.
49// ---------------------------------------------------------------------------
50
51static INPUT: OnceLock<io::Result<Mutex<File>>> = OnceLock::new();
52static OUTPUT: OnceLock<io::Result<Mutex<File>>> = OnceLock::new();
53
54fn input_lock() -> io::Result<&'static Mutex<File>> {
55    cached(INPUT.get_or_init(open_input))
56}
57
58fn output_lock() -> io::Result<&'static Mutex<File>> {
59    cached(OUTPUT.get_or_init(open_output))
60}
61
62fn cached(slot: &'static io::Result<Mutex<File>>) -> io::Result<&'static Mutex<File>> {
63    match slot {
64        Ok(m) => Ok(m),
65        // `io::Error` is not `Clone`; reconstruct the cached error
66        // with the same kind and message so each caller sees an
67        // equivalent value.
68        Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
69    }
70}
71
72#[cfg(unix)]
73fn open_input() -> io::Result<Mutex<File>> {
74    OpenOptions::new()
75        .read(true)
76        .write(true)
77        .open("/dev/tty")
78        .map(Mutex::new)
79}
80
81#[cfg(unix)]
82fn open_output() -> io::Result<Mutex<File>> {
83    // The output side gets its own `File` (a `dup` of the same
84    // underlying `/dev/tty`), so taking the input lock does not block
85    // writes and vice versa.
86    let file = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
87    Ok(Mutex::new(file))
88}
89
90#[cfg(windows)]
91fn open_input() -> io::Result<Mutex<File>> {
92    OpenOptions::new()
93        .read(true)
94        .write(true)
95        .open("CONIN$")
96        .map(Mutex::new)
97}
98
99#[cfg(windows)]
100fn open_output() -> io::Result<Mutex<File>> {
101    OpenOptions::new()
102        .read(true)
103        .write(true)
104        .open("CONOUT$")
105        .map(Mutex::new)
106}
107
108#[cfg(not(any(unix, windows)))]
109fn open_input() -> io::Result<Mutex<File>> {
110    Err(io::Error::new(
111        io::ErrorKind::Unsupported,
112        "open_tty is not available on this platform",
113    ))
114}
115
116#[cfg(not(any(unix, windows)))]
117fn open_output() -> io::Result<Mutex<File>> {
118    open_input()
119}
120
121/// Open the controlling terminal and return separate input/output handles.
122///
123/// On Unix both halves refer to `/dev/tty`; on Windows the input is
124/// `CONIN$` and the output is `CONOUT$`. The underlying handles are
125/// opened on the first successful call and cached for the lifetime of
126/// the process; every later call returns a fresh `Copy` handle that
127/// references the same cached state, so calling `open_tty` repeatedly
128/// is cheap.
129///
130/// # Returns
131///
132/// `(TtyInput, TtyOutput)` handles referencing the cached controlling terminal
133/// files.
134///
135/// # Errors
136///
137/// Returns an error if the process has no controlling terminal or if the
138/// device cannot be opened. Failures are also cached: once a platform open has
139/// failed, subsequent calls return an equivalent error without retrying.
140///
141/// # Panics
142///
143/// This function does not intentionally panic.
144pub fn open_tty() -> io::Result<(TtyInput, TtyOutput)> {
145    Ok((
146        TtyInput {
147            inner: input_lock()?,
148        },
149        TtyOutput {
150            inner: output_lock()?,
151        },
152    ))
153}
154
155/// Read end of the controlling terminal returned by `open_tty`.
156///
157/// A cheap `Copy` handle that references a process-wide cached
158/// [`File`] guarded by a [`Mutex`]; concurrent reads from multiple
159/// threads are serialised at the byte level.
160///
161/// Dropping this handle does not close the controlling terminal. Use it where
162/// an input handle implementing [`Read`] and the platform borrowing traits is
163/// needed, including [`Terminal`](super::Terminal).
164#[derive(Clone, Copy)]
165pub struct TtyInput {
166    inner: &'static Mutex<File>,
167}
168
169/// Write end of the controlling terminal returned by `open_tty`.
170///
171/// A cheap `Copy` handle that references a process-wide cached
172/// [`File`] guarded by a [`Mutex`]; concurrent writes from multiple
173/// threads are serialised at the byte level.
174///
175/// On Windows, [`Write::write`] detects when the underlying handle
176/// refers to a console and transcodes UTF-8 to UTF-16 +
177/// `WriteConsoleW` so non-ASCII text renders correctly; non-console
178/// handles (files or pipes) fall through to plain `WriteFile`.
179///
180/// Dropping this handle does not close the controlling terminal. Use it where
181/// an output handle implementing [`Write`] and the platform borrowing traits is
182/// needed, including [`Terminal`](super::Terminal).
183#[derive(Clone, Copy)]
184pub struct TtyOutput {
185    inner: &'static Mutex<File>,
186}
187
188impl fmt::Debug for TtyInput {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.debug_struct("TtyInput").finish()
191    }
192}
193
194impl fmt::Debug for TtyOutput {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        f.debug_struct("TtyOutput").finish()
197    }
198}
199
200impl Read for TtyInput {
201    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
202        (&*self).read(buf)
203    }
204}
205
206impl Read for &TtyInput {
207    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
208        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
209        (&*guard).read(buf)
210    }
211}
212
213impl Write for TtyOutput {
214    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
215        (&*self).write(buf)
216    }
217
218    fn flush(&mut self) -> io::Result<()> {
219        (&*self).flush()
220    }
221}
222
223impl Write for &TtyOutput {
224    #[cfg(windows)]
225    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
226        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
227        write_to_console_or_file(&guard, buf)
228    }
229
230    #[cfg(not(windows))]
231    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
232        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
233        (&*guard).write(buf)
234    }
235
236    fn flush(&mut self) -> io::Result<()> {
237        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
238        (&*guard).flush()
239    }
240}
241
242#[cfg(unix)]
243impl AsFd for TtyInput {
244    fn as_fd(&self) -> BorrowedFd<'_> {
245        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
246        // SAFETY: the cached `File` lives for the lifetime of the
247        // process and its fd never changes; the returned borrow is
248        // bounded by `&self` so it cannot outlive the caller's handle.
249        unsafe { BorrowedFd::borrow_raw(guard.as_raw_fd()) }
250    }
251}
252
253#[cfg(unix)]
254impl AsRawFd for TtyInput {
255    fn as_raw_fd(&self) -> RawFd {
256        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
257        guard.as_raw_fd()
258    }
259}
260
261#[cfg(unix)]
262impl AsFd for TtyOutput {
263    fn as_fd(&self) -> BorrowedFd<'_> {
264        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
265        // SAFETY: see `AsFd for TtyInput`.
266        unsafe { BorrowedFd::borrow_raw(guard.as_raw_fd()) }
267    }
268}
269
270#[cfg(unix)]
271impl AsRawFd for TtyOutput {
272    fn as_raw_fd(&self) -> RawFd {
273        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
274        guard.as_raw_fd()
275    }
276}
277
278#[cfg(windows)]
279impl AsHandle for TtyInput {
280    fn as_handle(&self) -> BorrowedHandle<'_> {
281        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
282        // SAFETY: the cached `File` lives for the lifetime of the
283        // process and its handle never changes; the returned borrow
284        // is bounded by `&self`.
285        unsafe { BorrowedHandle::borrow_raw(guard.as_raw_handle() as _) }
286    }
287}
288
289#[cfg(windows)]
290impl AsRawHandle for TtyInput {
291    fn as_raw_handle(&self) -> RawHandle {
292        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
293        guard.as_raw_handle()
294    }
295}
296
297#[cfg(windows)]
298impl AsHandle for TtyOutput {
299    fn as_handle(&self) -> BorrowedHandle<'_> {
300        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
301        // SAFETY: see `AsHandle for TtyInput`.
302        unsafe { BorrowedHandle::borrow_raw(guard.as_raw_handle() as _) }
303    }
304}
305
306#[cfg(windows)]
307impl AsRawHandle for TtyOutput {
308    fn as_raw_handle(&self) -> RawHandle {
309        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
310        guard.as_raw_handle()
311    }
312}
313
314#[cfg(windows)]
315fn is_console(h: RawHandle) -> bool {
316    use windows_sys::Win32::Foundation::HANDLE;
317    use windows_sys::Win32::System::Console::GetConsoleMode;
318    let mut mode: u32 = 0;
319    unsafe { GetConsoleMode(h as HANDLE, &mut mode) != 0 }
320}
321
322#[cfg(windows)]
323fn write_to_console_or_file(file: &File, buf: &[u8]) -> io::Result<usize> {
324    if !is_console(file.as_raw_handle()) {
325        return (&*file).write(buf);
326    }
327
328    // Console path: decode the largest UTF-8 prefix of `buf`, transcode
329    // it to UTF-16, and write it with WriteConsoleW. Any trailing
330    // partial codepoint stays in the caller's buffer (BufWriter will
331    // include it in the next call).
332    let utf8 = match std::str::from_utf8(buf) {
333        Ok(s) => s,
334        Err(e) => {
335            let valid = e.valid_up_to();
336            if valid == 0 {
337                // No complete codepoint at all: emit U+FFFD and report
338                // one byte consumed so the caller makes forward
339                // progress. Matches std's behavior on a console.
340                return write_utf16_console(file.as_raw_handle(), &['\u{FFFD}' as u16]).map(|_| 1);
341            }
342            // SAFETY: `valid` is the byte offset of the last valid
343            // UTF-8 boundary as reported by Utf8Error::valid_up_to.
344            unsafe { std::str::from_utf8_unchecked(&buf[..valid]) }
345        }
346    };
347
348    let utf16: Vec<u16> = utf8.encode_utf16().collect();
349    if utf16.is_empty() {
350        return Ok(0);
351    }
352    let units = write_utf16_console(file.as_raw_handle(), &utf16)?;
353
354    // Translate the count of UTF-16 code units back to the byte length
355    // of the corresponding UTF-8 prefix.
356    let mut consumed_units = 0usize;
357    let mut consumed_bytes = 0usize;
358    for c in utf8.chars() {
359        let cu = c.len_utf16();
360        if consumed_units + cu > units {
361            break;
362        }
363        consumed_units += cu;
364        consumed_bytes += c.len_utf8();
365        if consumed_units == units {
366            break;
367        }
368    }
369    Ok(consumed_bytes)
370}
371
372#[cfg(windows)]
373fn write_utf16_console(h: RawHandle, data: &[u16]) -> io::Result<usize> {
374    use windows_sys::Win32::Foundation::HANDLE;
375    use windows_sys::Win32::System::Console::WriteConsoleW;
376    let mut written: u32 = 0;
377    let ok = unsafe {
378        WriteConsoleW(
379            h as HANDLE,
380            data.as_ptr(),
381            data.len() as u32,
382            &mut written,
383            std::ptr::null(),
384        )
385    };
386    if ok == 0 {
387        return Err(io::Error::last_os_error());
388    }
389    Ok(written as usize)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn open_tty_returns_either_pair_or_error() {
398        // In CI / piped contexts there may be no controlling tty; both
399        // outcomes are acceptable. We're verifying that the call
400        // doesn't panic and produces a usable pair when one is
401        // available.
402        match open_tty() {
403            Ok((input, _output)) => {
404                #[cfg(unix)]
405                {
406                    assert!(input.as_raw_fd() >= 0);
407                }
408                #[cfg(windows)]
409                {
410                    let _ = input.as_raw_handle();
411                }
412                #[cfg(not(any(unix, windows)))]
413                {
414                    let _ = input;
415                }
416            }
417            Err(_e) => {
418                // Any io::Error is acceptable — in CI / piped
419                // contexts the error kind varies across platforms
420                // (NotFound, PermissionDenied, Unsupported, or
421                // Uncategorized ENXIO when no controlling tty is
422                // attached). The contract under test is just that
423                // open_tty surfaces a clean error rather than
424                // panicking.
425            }
426        }
427    }
428
429    #[test]
430    fn repeated_calls_share_underlying_handle() {
431        let Ok((input_a, output_a)) = open_tty() else {
432            return;
433        };
434        let Ok((input_b, output_b)) = open_tty() else {
435            unreachable!("second open_tty must succeed when the first did");
436        };
437
438        // Successive open_tty calls return Copy handles that wrap the
439        // same cached descriptors, not freshly-opened ones.
440        #[cfg(unix)]
441        {
442            assert_eq!(input_a.as_raw_fd(), input_b.as_raw_fd());
443            assert_eq!(output_a.as_raw_fd(), output_b.as_raw_fd());
444        }
445        #[cfg(windows)]
446        {
447            assert_eq!(input_a.as_raw_handle(), input_b.as_raw_handle());
448            assert_eq!(output_a.as_raw_handle(), output_b.as_raw_handle());
449        }
450    }
451}