Skip to main content

uncurses/event/
source_unix.rs

1//! Unix-specific construction and readiness servicing for [`EventSource`].
2//!
3//! ## Purpose
4//!
5//! This module supplies the Unix [`EventSource::new`] implementation and the
6//! platform hooks used by the shared source pump: drain the wake self-pipe, read
7//! ready bytes, and surface `SIGWINCH` as [`Event::Resize`].
8//!
9//! ```text
10//! [input fd] ─┐
11//! [wake pipe] ├─▶ Poller ──▶ EventSource::fill
12//! [winch pipe]┘        ├─ wake: Interrupted
13//!                      ├─ input: read + decode
14//!                      └─ winch: query Winsize + Resize
15//! ```
16//!
17//! ## Key types
18//!
19//! `UnixWakerInner` owns *both* ends of a non-blocking self-pipe. The shared
20//! [`Waker`] wraps it so other threads can interrupt blocking reads without
21//! touching decoder state. Both ends live behind the same `Arc` because a
22//! `Waker` may outlive its source, and writing to a pipe whose reader has
23//! closed raises a synchronous `SIGPIPE`.
24//!
25//! ## Gotchas
26//!
27//! The input fd is also used for `TIOCGWINSZ`, so it should refer to the same
28//! terminal whose size the caller wants. When in-band resize reports are
29//! enabled, [`EventSource::set_handle_resize`] should disable the SIGWINCH path
30//! to avoid duplicate resize events.
31#![cfg(unix)]
32
33use std::collections::VecDeque;
34use std::io;
35use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
36use std::sync::Arc;
37
38use super::decode::{Decoder, DecoderFlags};
39use super::pending::Pending;
40use super::poll::PollFd;
41use super::sigwinch as winch;
42use super::source::{
43    DEFAULT_BUFFER_CAPACITY, DEFAULT_ESC_TIMEOUT, DEFAULT_PASTE_IDLE_TIMEOUT, EventSource, Input,
44    Waker,
45};
46use crate::event::Event;
47use crate::terminal::get_window_size;
48
49pub(super) struct UnixWakerInner {
50    /// Write end of the self-pipe. Non-blocking; closed on drop.
51    tx: OwnedFd,
52    /// Read end, kept here rather than in [`EventSource`] so that it cannot be
53    /// closed while a `Waker` clone still holds `tx`. A write to a pipe whose
54    /// reader is gone raises a *synchronous* `SIGPIPE` on the calling thread,
55    /// which no error handling can intercept — invisible in a Rust binary
56    /// (std sets `SIG_IGN` at startup) but fatal inside a C or Go host.
57    /// `Waker` is public, `Clone` and `Send`, so it legitimately outlives the
58    /// source it came from; sharing both ends behind the same `Arc` makes that
59    /// safe instead of merely unlikely.
60    rx: OwnedFd,
61}
62
63impl UnixWakerInner {
64    pub(super) fn read_fd(&self) -> std::os::fd::RawFd {
65        self.rx.as_raw_fd()
66    }
67
68    pub(super) fn wake(&self) -> io::Result<()> {
69        let buf = b"w";
70        loop {
71            let n = unsafe { libc::write(self.tx.as_raw_fd(), buf.as_ptr() as *const _, 1) };
72            if n < 0 {
73                let err = io::Error::last_os_error();
74                match err.kind() {
75                    io::ErrorKind::Interrupted => continue,
76                    // Pipe full — an earlier wake byte is already pending,
77                    // which is all the consumer needs.
78                    io::ErrorKind::WouldBlock => return Ok(()),
79                    _ => return Err(err),
80                }
81            }
82            return Ok(());
83        }
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Unix implementation
89// ---------------------------------------------------------------------------
90
91impl<I> EventSource<I>
92where
93    I: Input,
94{
95    /// Build a new Unix event source for `input`.
96    ///
97    /// The handle is used both for byte reads and as the `TIOCGWINSZ` target
98    /// when `SIGWINCH` fires, so it should refer to the terminal whose size the
99    /// caller cares about. Construction creates two non-blocking self-pipes,
100    /// subscribes to the shared SIGWINCH fan-out, and registers the input, wake,
101    /// and resize fds with the selected poll backend.
102    ///
103    /// Timeouts start at [`DEFAULT_ESC_TIMEOUT`] and
104    /// [`DEFAULT_PASTE_IDLE_TIMEOUT`]; override them with
105    /// [`EventSource::with_esc_timeout`] and
106    /// [`EventSource::with_paste_idle_timeout`].
107    ///
108    /// Resize deduplication starts from the size `input` reports here, so a
109    /// `SIGWINCH` that does not actually change the size produces no
110    /// [`Event::Resize`].
111    ///
112    /// Returns any OS error from pipe creation, fd configuration, SIGWINCH
113    /// subscription, or poller construction. It does not read from `input`.
114    pub fn new(input: I) -> io::Result<Self> {
115        let (pipe_rx, pipe_tx) = make_self_pipe()?;
116        let winch_sub = winch::subscribe()?;
117        let waker = Waker::from_unix_inner(Arc::new(UnixWakerInner {
118            tx: pipe_tx,
119            rx: pipe_rx,
120        }));
121
122        // Watch input, wake pipe, and winch pipe — in the fixed index
123        // order the pump/ingest path relies on. Detect a tty input fd up
124        // front so Darwin can pick the select backend (its kqueue spins
125        // on tty character devices).
126        let input_fd = input.as_fd().as_raw_fd();
127        let input_is_tty = unsafe { libc::isatty(input_fd) } == 1;
128        // Seed the resize dedupe with the size we start at, so the first wake
129        // reports a resize only if one actually happened. Without this any
130        // first wake emits, including one caused by a late handler write that
131        // landed in this slot's pooled pipe just after it was leased to us.
132        // `None` on a non-tty, where there is no size to compare against.
133        let last_size = get_window_size(input.as_fd()).ok();
134        let fds: [PollFd; 3] = [input_fd, waker.pipe_read_fd(), winch_sub.read_fd()];
135        let poller = super::poll::new_poller(&fds, input_is_tty)?;
136
137        Ok(Self {
138            input,
139            parser: Decoder::new(DecoderFlags::empty()),
140            pending: Pending::with_capacity(DEFAULT_BUFFER_CAPACITY),
141            esc_timeout: DEFAULT_ESC_TIMEOUT,
142            esc_deadline: None,
143            paste_idle_timeout: Some(DEFAULT_PASTE_IDLE_TIMEOUT),
144            paste_deadline: None,
145            queue: VecDeque::with_capacity(16),
146            waker,
147            handle_resize: true,
148            poller,
149            winch_sub,
150            last_size,
151        })
152    }
153
154    /// Drain pending wake bytes after a [`Waker`] fired.
155    ///
156    /// Platform hook for [`EventSource::fill`]. Multiple wake bytes may have
157    /// coalesced; draining them all lets a subsequent poll block again.
158    pub(super) fn drain_wake(&mut self) {
159        drain_pipe(self.waker.pipe_read_fd());
160    }
161
162    /// Read ready input bytes and run them through the decoder.
163    ///
164    /// Platform hook for [`EventSource::fill`]. `Interrupted` and `WouldBlock`
165    /// reads are treated as a transient absence of bytes. A zero-length read is
166    /// surfaced as [`io::ErrorKind::UnexpectedEof`]. If the pending buffer is
167    /// already full, it is cleared because its capacity is the hard cap on one
168    /// undecoded sequence.
169    pub(super) fn drain_input(&mut self) -> io::Result<()> {
170        // If the buffer is full and the parser still couldn't extract
171        // an event, the contract says the buffer size is the hard cap
172        // on any single sequence — drop the buffer silently and resume.
173        if self.pending.is_full() {
174            self.pending.clear();
175            self.esc_deadline = None;
176        }
177        let n = match self.input.read(self.pending.spare_mut()) {
178            Ok(n) => n,
179            Err(e) => {
180                if matches!(
181                    e.kind(),
182                    io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock
183                ) {
184                    return Ok(());
185                }
186                return Err(e);
187            }
188        };
189        if n == 0 {
190            return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "input closed"));
191        }
192        self.pending.advance_written(n);
193        #[cfg(debug_assertions)]
194        {
195            let s = self.pending.slice();
196            crate::trace::tee_input(&s[s.len() - n..]);
197        }
198        self.drain_parser();
199        Ok(())
200    }
201
202    pub(super) fn handle_winch(&mut self) {
203        drain_pipe(self.winch_sub.read_fd());
204        // When in-band resize reporting is enabled the host disables
205        // this path; the terminal delivers resizes through the decoder
206        // instead, so emitting here too would duplicate every event.
207        if !self.handle_resize {
208            return;
209        }
210        let new_size = match get_window_size(self.input.as_fd()) {
211            Ok(sz) => sz,
212            Err(_) => return,
213        };
214        if Some(new_size) == self.last_size {
215            return;
216        }
217        self.last_size = Some(new_size);
218        self.emit(Event::Resize(new_size));
219    }
220}
221
222pub(super) fn make_self_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
223    let mut fds = [0i32; 2];
224    let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
225    if rc != 0 {
226        return Err(io::Error::last_os_error());
227    }
228    // SAFETY: pipe(2) just produced two fresh, owned fds.
229    let rx = unsafe { OwnedFd::from_raw_fd(fds[0]) };
230    let tx = unsafe { OwnedFd::from_raw_fd(fds[1]) };
231    set_nonblock_cloexec(rx.as_raw_fd())?;
232    set_nonblock_cloexec(tx.as_raw_fd())?;
233    Ok((rx, tx))
234}
235
236fn set_nonblock_cloexec(fd: i32) -> io::Result<()> {
237    unsafe {
238        let flags = libc::fcntl(fd, libc::F_GETFL);
239        if flags < 0 {
240            return Err(io::Error::last_os_error());
241        }
242        if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
243            return Err(io::Error::last_os_error());
244        }
245        let fd_flags = libc::fcntl(fd, libc::F_GETFD);
246        if fd_flags < 0 {
247            return Err(io::Error::last_os_error());
248        }
249        if libc::fcntl(fd, libc::F_SETFD, fd_flags | libc::FD_CLOEXEC) < 0 {
250            return Err(io::Error::last_os_error());
251        }
252    }
253    Ok(())
254}
255
256pub(super) fn drain_pipe(fd: i32) {
257    let mut buf = [0u8; 32];
258    loop {
259        let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
260        if n <= 0 {
261            break;
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::event::KeyCode;
270    use std::fs::File;
271    use std::os::fd::FromRawFd;
272    use std::thread;
273    use std::time::Duration;
274    use std::time::Instant;
275
276    fn make_pipe() -> (File, File) {
277        let mut fds = [0i32; 2];
278        let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
279        assert_eq!(rc, 0, "pipe() failed");
280        // SAFETY: pipe(2) produced two fresh, owned fds.
281        let rx = unsafe { File::from_raw_fd(fds[0]) };
282        let tx = unsafe { File::from_raw_fd(fds[1]) };
283        (rx, tx)
284    }
285
286    fn write_byte(f: &File, byte: u8) {
287        let n = unsafe { libc::write(f.as_raw_fd(), &byte as *const _ as *const _, 1) };
288        assert_eq!(n, 1);
289    }
290
291    fn write_bytes(f: &File, bytes: &[u8]) {
292        let n = unsafe { libc::write(f.as_raw_fd(), bytes.as_ptr() as *const _, bytes.len()) };
293        assert_eq!(n, bytes.len() as isize);
294    }
295
296    fn new_reader(input: File) -> EventSource<File> {
297        EventSource::new(input)
298            .unwrap()
299            .with_esc_timeout(Duration::from_millis(50))
300    }
301
302    #[test]
303    fn reads_event_from_input_fd() {
304        let (rx, tx) = make_pipe();
305        let mut src = new_reader(rx);
306        write_byte(&tx, b'a');
307        assert!(src.poll(Some(Duration::from_secs(1))).unwrap());
308        let ev = src.read().unwrap();
309        match ev {
310            Event::KeyPress(k) => assert_eq!(k.code, KeyCode::Char('a')),
311            other => panic!("unexpected event {:?}", other),
312        }
313    }
314
315    #[test]
316    fn timeout_returns_none() {
317        let (rx, _tx) = make_pipe();
318        let mut src = new_reader(rx);
319        let start = Instant::now();
320        let res = src.poll(Some(Duration::from_millis(10))).unwrap();
321        let elapsed = start.elapsed();
322        assert!(!res);
323        assert!(elapsed >= Duration::from_millis(5));
324    }
325
326    #[test]
327    fn waker_interrupts_blocking_read() {
328        let (rx, _tx) = make_pipe();
329        let mut src = new_reader(rx);
330        let waker = src.waker();
331        let handle = thread::spawn(move || {
332            thread::sleep(Duration::from_millis(20));
333            waker.wake().unwrap();
334        });
335        let err = src.read().expect_err("should be Interrupted");
336        handle.join().unwrap();
337        assert_eq!(err.kind(), io::ErrorKind::Interrupted);
338    }
339
340    #[test]
341    fn esc_resolves_before_late_continuation_byte() {
342        // A buffered partial ESC whose deadline elapses must resolve to a
343        // bare Esc before a continuation byte that arrives afterward is
344        // read, so the two never merge into an Alt-modified key.
345        let (rx, tx) = make_pipe();
346        let mut src = new_reader(rx); // 50ms esc timeout
347        write_bytes(&tx, b"\x1b");
348        // Drain the lone ESC so its disambiguation deadline is armed; the
349        // queue stays empty because the sequence is still partial.
350        assert!(!src.poll(Some(Duration::from_millis(0))).unwrap());
351        // Let the deadline elapse without draining, then deliver a byte
352        // that would otherwise complete an ESC-prefixed sequence.
353        thread::sleep(Duration::from_millis(80));
354        write_bytes(&tx, b"a");
355        let first = src.read().unwrap();
356        assert!(
357            matches!(&first, Event::KeyPress(k) if k.code == KeyCode::Escape),
358            "expected bare Esc, got {:?}",
359            first
360        );
361        let second = src.read().unwrap();
362        assert!(
363            matches!(&second, Event::KeyPress(k) if k.code == KeyCode::Char('a')),
364            "expected 'a', got {:?}",
365            second
366        );
367    }
368
369    #[test]
370    fn paste_idle_timeout_synthesizes_paste_end() {
371        let (rx, tx) = make_pipe();
372        let mut src = EventSource::new(rx)
373            .unwrap()
374            .with_paste_idle_timeout(Some(Duration::from_millis(40)));
375        write_bytes(&tx, b"\x1b[200~hello");
376        // Drain PasteStart + initial chunk.
377        let mut got_start = false;
378        let mut got_chunk = false;
379        for _ in 0..4 {
380            if !src.poll(Some(Duration::from_millis(20))).unwrap() {
381                break;
382            }
383            while let Some(ev) = src.try_read() {
384                match ev {
385                    Event::PasteStart => got_start = true,
386                    Event::PasteChunk(b) => {
387                        assert_eq!(b, b"hello".to_vec());
388                        got_chunk = true;
389                    }
390                    other => panic!("unexpected pre-timeout event {:?}", other),
391                }
392            }
393            if got_start && got_chunk {
394                break;
395            }
396        }
397        assert!(got_start && got_chunk);
398
399        // Now stop sending data and wait past the paste-idle deadline.
400        let start = Instant::now();
401        assert!(src.poll(Some(Duration::from_secs(5))).unwrap());
402        let ev = src.read().unwrap();
403        let elapsed = start.elapsed();
404        assert_eq!(ev, Event::PasteEnd);
405        assert!(
406            elapsed < Duration::from_millis(500),
407            "elapsed = {:?}",
408            elapsed
409        );
410    }
411
412    #[test]
413    fn paste_completes_when_terminator_arrives_within_idle_window() {
414        let (rx, tx) = make_pipe();
415        let mut src = EventSource::new(rx)
416            .unwrap()
417            .with_paste_idle_timeout(Some(Duration::from_millis(500)));
418        write_bytes(&tx, b"\x1b[200~hi");
419        let _ = src.poll(Some(Duration::from_millis(50))).unwrap();
420        while src.try_read().is_some() {}
421        // Sub-timeout pause, then deliver the terminator.
422        thread::sleep(Duration::from_millis(50));
423        write_bytes(&tx, b"\x1b[201~");
424        assert!(src.poll(Some(Duration::from_secs(1))).unwrap());
425        let mut saw_end = false;
426        while let Some(ev) = src.try_read() {
427            if matches!(ev, Event::PasteEnd) {
428                saw_end = true;
429            }
430        }
431        if !saw_end {
432            // Drain another pump cycle if necessary.
433            let _ = src.poll(Some(Duration::from_millis(50))).unwrap();
434            while let Some(ev) = src.try_read() {
435                if matches!(ev, Event::PasteEnd) {
436                    saw_end = true;
437                }
438            }
439        }
440        assert!(saw_end, "expected PasteEnd within the idle window");
441    }
442
443    #[test]
444    fn explicit_end_paste_recovers_stream() {
445        let (rx, tx) = make_pipe();
446        let mut src = EventSource::new(rx).unwrap().with_paste_idle_timeout(None);
447        write_bytes(&tx, b"\x1b[200~stuck");
448        let _ = src.poll(Some(Duration::from_millis(50))).unwrap();
449        while src.try_read().is_some() {}
450
451        // No terminator will arrive; force-exit.
452        src.end_paste();
453        let ev = src.try_read().expect("PasteEnd should be queued");
454        assert_eq!(ev, Event::PasteEnd);
455
456        // Subsequent bytes parse as normal input again.
457        write_bytes(&tx, b"a");
458        assert!(src.poll(Some(Duration::from_secs(1))).unwrap());
459        let ev = src.read().unwrap();
460        assert!(matches!(
461            ev,
462            Event::KeyPress(ref k) if k.code == KeyCode::Char('a')
463        ));
464    }
465
466    #[test]
467    fn paste_idle_timeout_disabled_blocks_indefinitely() {
468        let (rx, tx) = make_pipe();
469        let mut src = EventSource::new(rx).unwrap().with_paste_idle_timeout(None);
470        write_bytes(&tx, b"\x1b[200~partial");
471        let _ = src.poll(Some(Duration::from_millis(50))).unwrap();
472        while src.try_read().is_some() {}
473
474        // With the safety net disabled, a long-but-finite caller
475        // timeout should expire without synthesising PasteEnd.
476        let res = src.poll(Some(Duration::from_millis(80))).unwrap();
477        assert!(!res, "should time out, not synthesise PasteEnd");
478        assert!(src.try_read().is_none());
479    }
480
481    #[test]
482    fn esc_deadline_does_not_fire_during_paste() {
483        // Pre-fix latent bug: while in paste, a partial ESC at the
484        // head of the pending buffer must not synthesise Key(Esc).
485        let (rx, tx) = make_pipe();
486        let mut src = EventSource::new(rx)
487            .unwrap()
488            .with_esc_timeout(Duration::from_millis(20))
489            .with_paste_idle_timeout(Some(Duration::from_secs(5)));
490        write_bytes(&tx, b"\x1b[200~body");
491        let _ = src.poll(Some(Duration::from_millis(50))).unwrap();
492        while src.try_read().is_some() {}
493
494        // Send only the beginning of the terminator: a partial ESC
495        // sequence at the head of pending. The esc_timeout (20 ms)
496        // must NOT fire — only the paste timeout (5 s) governs here.
497        write_bytes(&tx, b"\x1b[20");
498        let _ = src.poll(Some(Duration::from_millis(80))).unwrap();
499        let mut saw_esc = false;
500        while let Some(ev) = src.try_read() {
501            if matches!(ev, Event::KeyPress(ref k) if k.code == KeyCode::Escape) {
502                saw_esc = true;
503            }
504        }
505        assert!(!saw_esc, "esc deadline must not fire during paste");
506
507        // Complete the terminator: paste ends cleanly.
508        write_bytes(&tx, b"1~");
509        assert!(src.poll(Some(Duration::from_secs(1))).unwrap());
510        let mut saw_end = false;
511        while let Some(ev) = src.try_read() {
512            if matches!(ev, Event::PasteEnd) {
513                saw_end = true;
514            }
515        }
516        assert!(saw_end);
517    }
518
519    #[test]
520    fn esc_deadline_tightens_long_caller_timeout() {
521        let (rx, tx) = make_pipe();
522        let mut src = EventSource::new(rx)
523            .unwrap()
524            .with_esc_timeout(Duration::from_millis(20));
525        write_byte(&tx, 0x1b);
526        let _ = src.poll(Some(Duration::from_secs(60))).unwrap();
527        let start = Instant::now();
528        assert!(src.poll(Some(Duration::from_secs(60))).unwrap());
529        let ev = src.read().unwrap();
530        let elapsed = start.elapsed();
531        assert!(matches!(ev, Event::KeyPress(k) if k.code == KeyCode::Escape));
532        assert!(
533            elapsed < Duration::from_millis(500),
534            "elapsed = {:?}",
535            elapsed
536        );
537    }
538
539    #[test]
540    fn paste_end_after_chunk_is_delivered_without_extra_input() {
541        // Regression: when a paste body and its closing terminator arrive
542        // in the same read, the decoder returns the chunk first and queues
543        // PasteEnd on its internal pending list. The source must drain that
544        // queued event in the same drain pass — otherwise PasteEnd would
545        // stall until the next byte showed up.
546        let (rx, tx) = make_pipe();
547        let mut src = new_reader(rx);
548        write_bytes(&tx, b"\x1b[200~hello\x1b[201~");
549        assert!(src.poll(Some(Duration::from_secs(1))).unwrap());
550        assert!(matches!(src.read().unwrap(), Event::PasteStart));
551        assert!(matches!(src.read().unwrap(), Event::PasteChunk(ref b) if b == b"hello"));
552        assert!(matches!(src.read().unwrap(), Event::PasteEnd));
553    }
554
555    #[test]
556    fn handle_resize_false_suppresses_sigwinch_resize_event() {
557        // With resize handling disabled (the host has enabled in-band
558        // reports), a SIGWINCH must drain its wake pipe but surface no
559        // Event::Resize — the decoder delivers resizes in-band instead.
560        let stderr_fd = 2;
561        let ws: libc::winsize = unsafe { std::mem::zeroed() };
562        let probe = unsafe { libc::ioctl(stderr_fd, libc::TIOCGWINSZ, &ws as *const _) };
563        if probe < 0 {
564            return;
565        }
566        let stderr_dup = unsafe { libc::dup(stderr_fd) };
567        assert!(stderr_dup >= 0);
568        let stderr_file = unsafe { File::from_raw_fd(stderr_dup) };
569        let mut src = new_reader(stderr_file);
570        assert!(src.handle_resize());
571        src.set_handle_resize(false);
572        assert!(!src.handle_resize());
573        src.last_size = None;
574        unsafe { libc::raise(libc::SIGWINCH) };
575        // No event is produced; the poll runs to its (short) timeout.
576        assert!(!src.poll(Some(Duration::from_millis(50))).unwrap());
577        assert!(src.try_read().is_none());
578    }
579
580    /// The constructor seeds `last_size`, so a `SIGWINCH` that does not change
581    /// the size must not surface an event. That is what keeps a stray wake on a
582    /// recycled pool pipe from being mistaken for a resize.
583    #[cfg(not(target_os = "l4re"))]
584    #[test]
585    fn sigwinch_dedups_unchanged_size() {
586        let Some((master, _slave)) = crate::testutil::open_pty_pair() else {
587            return;
588        };
589        // Probe independently of the code under test: illumos ptys are STREAMS
590        // devices and a bare master does not answer TIOCGWINSZ. Skipping on the
591        // probe rather than on `last_size` keeps the assertion below meaningful
592        // everywhere the ioctl does work.
593        let ws: libc::winsize = unsafe { std::mem::zeroed() };
594        if unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGWINSZ, &ws) } < 0 {
595            return;
596        }
597
598        let mut src = new_reader(master);
599        assert!(
600            src.last_size.is_some(),
601            "constructor did not seed the resize dedupe from the input fd"
602        );
603
604        unsafe { libc::raise(libc::SIGWINCH) };
605        assert!(
606            !src.poll(Some(Duration::from_millis(50))).unwrap(),
607            "an unchanged size surfaced a resize event"
608        );
609        assert!(src.try_read().is_none());
610    }
611
612    #[test]
613    fn sigwinch_surfaces_resize_event() {
614        // SIGWINCH requires a real tty to query TIOCGWINSZ on. Dup
615        // stderr — under cargo test it is typically a tty — and use
616        // that fd as the input source. If stderr isn't a tty, skip.
617        let stderr_fd = 2;
618        let ws: libc::winsize = unsafe { std::mem::zeroed() };
619        let probe = unsafe { libc::ioctl(stderr_fd, libc::TIOCGWINSZ, &ws as *const _) };
620        if probe < 0 {
621            return;
622        }
623        let stderr_dup = unsafe { libc::dup(stderr_fd) };
624        assert!(stderr_dup >= 0);
625        let stderr_file = unsafe { File::from_raw_fd(stderr_dup) };
626        let mut src = new_reader(stderr_file);
627        // Force a mismatched cached size so the SIGWINCH path surfaces
628        // the dedupe-suppressed event.
629        src.last_size = None;
630        unsafe { libc::raise(libc::SIGWINCH) };
631        assert!(src.poll(Some(Duration::from_secs(1))).unwrap());
632        let ev = src.read().unwrap();
633        assert!(matches!(ev, Event::Resize(_)));
634    }
635}