• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / term-wm / 29128907872

10 Jul 2026 10:52PM UTC coverage: 74.017% (+0.3%) from 73.747%
29128907872

push

github

web-flow
feat: Windows platform hardening, cross-platform test coverage, and regression safeguards  (#102)

* feat: Windows platform hardening, cross-platform test coverage, and
regression safeguards (0.8.19-alpha)
Addresses multiple Windows-specific reliability issues and establishes
comprehensive test coverage to prevent regressions across platforms.
Windows ConPTY lifecycle fixes:
- Synthesize PtyStatus::Exited callback in has_exited() when child
  process dies, preventing reader thread deadlock when ConPTY swallows
  EOF
- Clamp poll interval to 50ms on Windows (WINDOWS_MAX_POLL_INTERVAL) to
  prevent ConPTY thread starvation
- Activate native Windows FFI via kernel32 module:
  CreateToolhelp32Snapshot, OpenProcess, QueryFullProcessImageNameW for
  process tree walking and foreground process name detection
  Input normalization:
- Normalize events through KeyboardNormalizer in both drain_pending()
  and poll(), filtering Release (all platforms) and Repeat (Windows)
  before they reach the event buffer
- Remove esc_down stateful tracking from KeyboardNormalizer — now a unit
  struct. Esc deduplication handled by window manager's
  super_passthrough_window timeout instead of dropping presses
  Selection coordinate fix:
- Rewrite selection_text_for_range with ScrollbackGuard RAII guard for
  safe scrollback position restoration
- Extract max_scrollback before locking parser to prevent Mutex
  deadlocks
- Paginated extraction loop handles selections spanning
  viewport/scrollback boundaries using translated vt100 coordinates
  Cross-platform test infrastructure:
- Add get_test_executable() helper returning cat (Unix) or cmd.exe
  (Windows), replacing all 11 hardcoded "cat" strings in PTY tests
- 10 new tests covering: has_exited() callback firing and idempotency,
  event normalization in drain/poll, header_buttons() glyph and
  ordering, scrollback selection with small max_sb,
  WINDOWS_MAX_POLL_INTERVAL value, and Repeat key passthrough behavior
  Other:
- Cl... (continued)

268 of 284 new or added lines in 5 files covered. (94.37%)

2 existing lines in 1 file now uncovered.

21203 of 28646 relevant lines covered (74.02%)

201.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.6
/src/unified_event_source.rs
1
use std::collections::{HashSet, VecDeque};
2
use std::io;
3
use std::sync::{
4
    Arc,
5
    atomic::{AtomicBool, Ordering},
6
};
7
use std::thread::{self, JoinHandle};
8
use std::time::{Duration, Instant};
9

10
use crossbeam_channel::{Receiver, Sender, bounded};
11

12
use term_wm_core::events::{
13
    Event, KeyCode, KeyEvent, KeyKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
14
};
15
use term_wm_core::io::EventSource;
16
use term_wm_core::io::frame_pacer::FramePacer;
17
use term_wm_core::power_profile::PowerProfile;
18
use term_wm_core::utils::KeyboardNormalizer;
19
use term_wm_core::window::WindowKey;
20

21
/// Capacity of the crossbeam channel between event producers and the event
22
/// loop. Generous capacity since wakeup gating (dirty.swap) prevents flooding.
23
const EVENT_CHANNEL_CAPACITY: usize = 256;
24

25
/// How often the crossterm input thread polls for new events (100 ms).
26
/// Keeps the thread responsive to shutdown signals while being idle-friendly.
27
const INPUT_THREAD_POLL_INTERVAL: Duration = Duration::from_millis(100);
28

29
/// Events that can flow through the unified event channel.
30
#[derive(Debug, Clone)]
31
pub enum UnifiedEvent {
32
    /// A user-input event from crossterm (key, mouse, resize).
33
    Input(Event),
34
    /// A PTY reader thread has new data available for `WindowKey`.
35
    PtyWakeup(WindowKey),
36
    /// A PTY child process has exited. Sent from the reader thread on EOF.
37
    AppExited(WindowKey),
38
    /// An OS signal was received (SIGINT, SIGTERM).
39
    Signal,
40
    /// Periodic tick for timing.
41
    Tick,
42
}
43

44
/// A unified event source that multiplexes crossterm input, PTY wakeups,
45
/// and OS signals into a single channel.  The main thread blocks on one
46
/// receiver instead of polling multiple sources.
47
pub struct UnifiedEventSource {
48
    rx: Receiver<UnifiedEvent>,
49
    /// One Sender clone kept here so we can create it before the input thread.
50
    tx: Sender<UnifiedEvent>,
51
    /// Handle to the crossterm input thread.
52
    _input_handle: JoinHandle<()>,
53
    /// When true, the input thread should shut down.
54
    shutdown: Arc<AtomicBool>,
55
    /// Accumulated PTY wakeups since the last idle tick — batch-drained
56
    /// so thousands of wakeups/sec collapse into a single render.
57
    dirty_windows: HashSet<WindowKey>,
58
    /// Accumulated window exit notifications since the last drain.
59
    exited_windows: Vec<WindowKey>,
60
    /// Cached input event (poll returned true, waiting for read).
61
    pending_event: Option<Event>,
62
    /// Buffer for input events drained during `drain_pending` so none are lost.
63
    input_buffer: VecDeque<Event>,
64
    /// Signal received flag.
65
    signal_received: bool,
66
    /// Keyboard normalizer for consistent event handling.
67
    normalizer: KeyboardNormalizer,
68
    /// Timestamp of the last input event (for power profiling).
69
    last_event_at: Option<Instant>,
70
    /// Frame pacing: ensures renders fire at most once per 16ms interval.
71
    frame_pacer: FramePacer,
72
    /// Set by the runner via [`EventSource::set_pending_work`] when there's
73
    /// pending work (e.g. countdown timer) that requires frequent polling
74
    /// regardless of PTY dirty-window state.
75
    pending_work: bool,
76
    /// Maximum duration the next [`poll`] call is allowed to block.
77
    /// Set by the runner via [`EventSource::set_max_sleep_duration`] to
78
    /// clamp the PowerSaver poll interval to the next scheduler deadline.
79
    ///
80
    /// [`poll`]: EventSource::poll
81
    /// [`set_max_sleep_duration`]: EventSource::set_max_sleep_duration
82
    max_sleep_duration: Option<Duration>,
83
}
84

85
impl UnifiedEventSource {
86
    /// Create a new unified event source, spawning a background thread
87
    /// that reads crossterm events.
88
    ///
89
    /// The bounded channel (256 slots) provides mechanical backpressure:
90
    /// when the channel is full, PTY reader threads block on `send()` →
91
    /// OS pipe buffer fills → child process `write()` blocks → prevents
92
    /// memory exhaustion under extreme output load.
93
    pub fn new() -> io::Result<Self> {
×
94
        let (tx, rx) = bounded::<UnifiedEvent>(EVENT_CHANNEL_CAPACITY);
×
95
        let shutdown = Arc::new(AtomicBool::new(false));
×
96
        let input_tx = tx.clone();
×
97
        let input_shutdown = Arc::clone(&shutdown);
×
98

99
        let _input_handle = thread::Builder::new()
×
100
            .name("crossterm-input".into())
×
101
            .spawn(move || {
×
102
                // Loop: poll crossterm with 100ms timeout, check shutdown flag.
103
                loop {
104
                    if input_shutdown.load(Ordering::Acquire) {
×
105
                        break;
×
106
                    }
×
107
                    // Blocking poll with short timeout so we can check shutdown.
108
                    if crossterm::event::poll(INPUT_THREAD_POLL_INTERVAL).unwrap_or(false) {
×
109
                        match crossterm::event::read() {
×
110
                            Ok(crossterm_event) => {
×
111
                                // Translate crossterm event to core-owned event
112
                                if let Some(core_event) = translate_crossterm_event(crossterm_event)
×
113
                                    && input_tx.send(UnifiedEvent::Input(core_event)).is_err()
×
114
                                {
115
                                    break; // receiver dropped
×
116
                                }
×
117
                            }
118
                            Err(_) => break,
×
119
                        }
120
                    }
×
121
                }
122
            })
×
123
            .map_err(|e| io::Error::other(e.to_string()))?;
×
124

125
        Ok(Self {
×
126
            rx,
×
127
            tx,
×
128
            _input_handle,
×
129
            shutdown,
×
130
            dirty_windows: HashSet::new(),
×
131
            exited_windows: Vec::new(),
×
132
            pending_event: None,
×
133
            input_buffer: VecDeque::new(),
×
134
            signal_received: false,
×
135
            normalizer: KeyboardNormalizer::new(),
×
136
            last_event_at: None,
×
137
            frame_pacer: FramePacer::new(),
×
138
            pending_work: false,
×
139
            max_sleep_duration: None,
×
140
        })
×
141
    }
×
142

143
    /// Return a sender that PTY reader threads can use to send wakeup pings.
144
    pub fn pty_wakeup_tx(&self) -> Sender<UnifiedEvent> {
×
145
        self.tx.clone()
×
146
    }
×
147

148
    /// Drain all pending events from the channel (non-blocking) into internal
149
    /// state.  Called at the start of each event-loop iteration so PtyWakeup
150
    /// floods don't cause render-backlog.
151
    ///
152
    /// Input events are moved into `input_buffer` so none are lost during
153
    /// bursts (paste, key repeat).  `poll()` checks the buffer first.
154
    fn drain_pending(&mut self) {
6✔
155
        loop {
156
            match self.rx.try_recv() {
27✔
157
                Ok(UnifiedEvent::Input(event)) => {
17✔
158
                    if let Some(normalized) = self.normalizer.normalize(event) {
17✔
159
                        self.input_buffer.push_back(normalized);
13✔
160
                    }
13✔
161
                }
162
                Ok(UnifiedEvent::PtyWakeup(key)) => {
4✔
163
                    self.dirty_windows.insert(key);
4✔
164
                }
4✔
165
                Ok(UnifiedEvent::AppExited(key)) => {
×
166
                    self.exited_windows.push(key);
×
167
                }
×
168
                Ok(UnifiedEvent::Signal) => {
×
169
                    self.signal_received = true;
×
170
                }
×
171
                Ok(UnifiedEvent::Tick) => {
×
172
                    // No-op — tick is implicit in the event-cycle loop.
×
173
                }
×
174
                Err(crossbeam_channel::TryRecvError::Empty) => break,
6✔
175
                Err(crossbeam_channel::TryRecvError::Disconnected) => break,
×
176
            }
177
        }
178
    }
6✔
179

180
    /// Check if a signal was received and ack it.
181
    pub fn take_signal(&mut self) -> bool {
×
182
        let sig = self.signal_received;
×
183
        self.signal_received = false;
×
184
        sig
×
185
    }
×
186
}
187

188
/// Translate a crossterm event to a core-owned event.
189
fn translate_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
×
190
    match evt {
×
191
        crossterm::event::Event::Key(key) => Some(Event::Key(translate_key_event(key))),
×
192
        crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(translate_mouse_event(mouse))),
×
193
        crossterm::event::Event::Resize(w, h) => Some(Event::Resize(w, h)),
×
194
        crossterm::event::Event::FocusGained => Some(Event::FocusGained),
×
195
        crossterm::event::Event::FocusLost => Some(Event::FocusLost),
×
196
        crossterm::event::Event::Paste(text) => Some(Event::Paste(text)),
×
197
    }
198
}
×
199

200
fn translate_key_event(key: crossterm::event::KeyEvent) -> KeyEvent {
×
201
    let mut modifiers = translate_key_modifiers(key.modifiers);
×
202
    // macOS sends BackTab for Shift+Tab — set shift
203
    if matches!(key.code, crossterm::event::KeyCode::BackTab) {
×
204
        modifiers.shift = true;
×
205
    }
×
206
    KeyEvent {
207
        code: translate_key_code(key.code),
×
208
        modifiers,
×
209
        kind: match key.kind {
×
210
            crossterm::event::KeyEventKind::Press => KeyKind::Press,
×
211
            crossterm::event::KeyEventKind::Repeat => KeyKind::Repeat,
×
212
            crossterm::event::KeyEventKind::Release => KeyKind::Release,
×
213
        },
214
    }
215
}
×
216

217
fn translate_key_code(code: crossterm::event::KeyCode) -> KeyCode {
×
218
    match code {
×
219
        crossterm::event::KeyCode::Char(c) => KeyCode::Char(c),
×
220
        crossterm::event::KeyCode::Enter => KeyCode::Enter,
×
221
        crossterm::event::KeyCode::Tab => KeyCode::Tab,
×
222
        crossterm::event::KeyCode::BackTab => KeyCode::Tab,
×
223
        crossterm::event::KeyCode::Backspace => KeyCode::Backspace,
×
224
        crossterm::event::KeyCode::Esc => KeyCode::Esc,
×
225
        crossterm::event::KeyCode::Left => KeyCode::Left,
×
226
        crossterm::event::KeyCode::Right => KeyCode::Right,
×
227
        crossterm::event::KeyCode::Up => KeyCode::Up,
×
228
        crossterm::event::KeyCode::Down => KeyCode::Down,
×
229
        crossterm::event::KeyCode::Home => KeyCode::Home,
×
230
        crossterm::event::KeyCode::End => KeyCode::End,
×
231
        crossterm::event::KeyCode::PageUp => KeyCode::PageUp,
×
232
        crossterm::event::KeyCode::PageDown => KeyCode::PageDown,
×
233
        crossterm::event::KeyCode::Delete => KeyCode::Delete,
×
234
        crossterm::event::KeyCode::Insert => KeyCode::Insert,
×
235
        crossterm::event::KeyCode::F(n) => KeyCode::F(n),
×
236
        _ => KeyCode::Esc, // Fallback for unrecognized keys
×
237
    }
238
}
×
239

240
fn translate_key_modifiers(mods: crossterm::event::KeyModifiers) -> KeyModifiers {
×
241
    KeyModifiers {
×
242
        shift: mods.contains(crossterm::event::KeyModifiers::SHIFT),
×
243
        control: mods.contains(crossterm::event::KeyModifiers::CONTROL),
×
244
        alt: mods.contains(crossterm::event::KeyModifiers::ALT),
×
245
    }
×
246
}
×
247

248
fn translate_mouse_event(mouse: crossterm::event::MouseEvent) -> MouseEvent {
×
249
    MouseEvent {
250
        kind: match mouse.kind {
×
251
            crossterm::event::MouseEventKind::Down(btn) => {
×
252
                MouseEventKind::Press(translate_button(btn))
×
253
            }
254
            crossterm::event::MouseEventKind::Up(btn) => {
×
255
                MouseEventKind::Release(translate_button(btn))
×
256
            }
257
            crossterm::event::MouseEventKind::Drag(btn) => {
×
258
                MouseEventKind::Drag(translate_button(btn))
×
259
            }
260
            crossterm::event::MouseEventKind::Moved => MouseEventKind::Moved,
×
261
            crossterm::event::MouseEventKind::ScrollUp => MouseEventKind::ScrollUp,
×
262
            crossterm::event::MouseEventKind::ScrollDown => MouseEventKind::ScrollDown,
×
263
            crossterm::event::MouseEventKind::ScrollLeft => MouseEventKind::ScrollLeft,
×
264
            crossterm::event::MouseEventKind::ScrollRight => MouseEventKind::ScrollRight,
×
265
        },
266
        modifiers: translate_key_modifiers(mouse.modifiers),
×
267
        column: mouse.column,
×
268
        row: mouse.row,
×
269
    }
270
}
×
271

272
fn translate_button(btn: crossterm::event::MouseButton) -> MouseButton {
×
273
    match btn {
×
274
        crossterm::event::MouseButton::Left => MouseButton::Left,
×
275
        crossterm::event::MouseButton::Right => MouseButton::Right,
×
276
        crossterm::event::MouseButton::Middle => MouseButton::Middle,
×
277
    }
278
}
×
279

280
impl EventSource for UnifiedEventSource {
281
    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
4✔
282
        // First drain any pending events non-blocking.
283
        self.drain_pending();
4✔
284

285
        if self.pending_event.is_some() || !self.input_buffer.is_empty() {
4✔
286
            return Ok(true);
1✔
287
        }
3✔
288

289
        // If drain_pending found dirty windows, arm the frame deadline.
290
        // This prevents a 3600s freeze when a PtyWakeup arrives between
291
        // handler(None) and drain_pending (common under heavy streaming):
292
        // the PtyWakeup is consumed by drain_pending but no frame
293
        // deadline is set, so recv_timeout would block for the full
294
        // PowerSaver interval.
295
        if !self.dirty_windows.is_empty() {
3✔
296
            self.frame_pacer.notify_pending();
2✔
297
        }
2✔
298

299
        // Clamp remaining to the frame deadline so we never block
300
        // longer than 16ms when there are unprocessed dirty windows.
301
        if self.frame_pacer.try_expire() {
3✔
302
            return Ok(false);
×
303
        }
3✔
304
        let mut remaining = timeout;
3✔
305
        if let Some(t) = self.frame_pacer.time_until_deadline() {
3✔
306
            remaining = remaining.min(t);
2✔
307
        }
2✔
308

309
        while remaining > Duration::ZERO {
3✔
310
            // Check frame deadline before each blocking call.
311
            if self.frame_pacer.try_expire() {
2✔
312
                return Ok(false);
×
313
            }
2✔
314
            if let Some(t) = self.frame_pacer.time_until_deadline() {
2✔
315
                remaining = remaining.min(t);
1✔
316
                if remaining <= Duration::ZERO {
1✔
317
                    self.frame_pacer.reset();
×
318
                    return Ok(false);
×
319
                }
1✔
320
            }
1✔
321

322
            match self.rx.recv_timeout(remaining) {
2✔
323
                Ok(UnifiedEvent::Input(event)) => {
×
324
                    self.last_event_at = Some(Instant::now());
×
NEW
325
                    if let Some(normalized) = self.normalizer.normalize(event) {
×
NEW
326
                        self.frame_pacer.reset();
×
NEW
327
                        self.pending_event = Some(normalized);
×
NEW
328
                        return Ok(true);
×
NEW
329
                    }
×
330
                    // Event filtered out — update remaining and continue.
NEW
331
                    if let Some(t) = self.frame_pacer.time_until_deadline() {
×
NEW
332
                        remaining = remaining.min(t);
×
NEW
333
                    }
×
NEW
334
                    continue;
×
335
                }
336
                Ok(UnifiedEvent::PtyWakeup(key)) => {
×
337
                    self.dirty_windows.insert(key);
×
338
                    self.frame_pacer.notify_pending();
×
339
                    if self.frame_pacer.try_expire() {
×
340
                        return Ok(false);
×
341
                    }
×
342
                    if let Some(t) = self.frame_pacer.time_until_deadline() {
×
343
                        remaining = remaining.min(t);
×
344
                    }
×
345
                    continue;
×
346
                }
347
                Ok(UnifiedEvent::AppExited(key)) => {
×
348
                    self.exited_windows.push(key);
×
349
                    self.frame_pacer.notify_pending();
×
350
                    continue;
×
351
                }
352
                Ok(UnifiedEvent::Signal) => {
353
                    self.signal_received = true;
×
354
                    return Ok(false);
×
355
                }
356
                Ok(UnifiedEvent::Tick) => {
357
                    return Ok(false);
×
358
                }
359
                Err(_) => {
360
                    // Check if the frame deadline expired during the wait.
361
                    if self.frame_pacer.try_expire() {
2✔
362
                        return Ok(false);
1✔
363
                    }
1✔
364
                    break;
1✔
365
                }
366
            }
367
        }
368

369
        self.frame_pacer.reset();
2✔
370
        Ok(false)
2✔
371
    }
4✔
372

373
    fn read(&mut self) -> io::Result<Event> {
10✔
374
        // Check pending_event first (set by poll()), then drain input_buffer.
375
        if let Some(event) = self.pending_event.take()
10✔
376
            && let Some(normalized) = self.normalizer.normalize(event)
×
377
        {
378
            return Ok(normalized);
×
379
        }
10✔
380
        // Fallback: check buffer, then block on the channel.
381
        loop {
382
            if let Some(event) = self.input_buffer.pop_front() {
10✔
383
                self.last_event_at = Some(Instant::now());
10✔
384
                if let Some(normalized) = self.normalizer.normalize(event) {
10✔
385
                    return Ok(normalized);
10✔
386
                }
×
387
                continue;
×
388
            }
×
389
            match self.rx.recv() {
×
390
                Ok(UnifiedEvent::Input(event)) => {
×
391
                    self.last_event_at = Some(Instant::now());
×
392
                    if let Some(normalized) = self.normalizer.normalize(event) {
×
393
                        return Ok(normalized);
×
394
                    }
×
395
                }
396
                Ok(UnifiedEvent::PtyWakeup(key)) => {
×
397
                    self.dirty_windows.insert(key);
×
398
                }
×
399
                Ok(UnifiedEvent::AppExited(key)) => {
×
400
                    self.exited_windows.push(key);
×
401
                }
×
402
                Ok(UnifiedEvent::Signal) => {
×
403
                    self.signal_received = true;
×
404
                }
×
405
                Ok(UnifiedEvent::Tick) => {}
×
406
                Err(_) => {
407
                    return Err(io::Error::new(
×
408
                        io::ErrorKind::BrokenPipe,
×
409
                        "event channel disconnected",
×
410
                    ));
×
411
                }
412
            }
413
        }
414
    }
10✔
415

416
    fn next_key(&mut self) -> io::Result<KeyEvent> {
×
417
        loop {
418
            self.drain_pending();
×
419
            if let Some(Event::Key(key)) = self.pending_event.take() {
×
420
                return Ok(key);
×
421
            }
×
422
            match self.rx.recv() {
×
423
                Ok(UnifiedEvent::Input(Event::Key(key))) => return Ok(key),
×
424
                Ok(UnifiedEvent::Input(event)) => {
×
425
                    self.pending_event = Some(event);
×
426
                }
×
427
                Ok(UnifiedEvent::PtyWakeup(_)) => {}
×
428
                Ok(UnifiedEvent::AppExited(key)) => {
×
429
                    self.exited_windows.push(key);
×
430
                }
×
431
                Ok(UnifiedEvent::Signal) => self.signal_received = true,
×
432
                Ok(UnifiedEvent::Tick) => {}
×
433
                Err(_) => {
434
                    return Err(io::Error::new(
×
435
                        io::ErrorKind::BrokenPipe,
×
436
                        "event channel disconnected",
×
437
                    ));
×
438
                }
439
            }
440
        }
441
    }
×
442

443
    fn next_mouse(&mut self) -> io::Result<MouseEvent> {
×
444
        loop {
445
            self.drain_pending();
×
446
            if let Some(Event::Mouse(mouse)) = self.pending_event.take() {
×
447
                return Ok(mouse);
×
448
            }
×
449
            match self.rx.recv() {
×
450
                Ok(UnifiedEvent::Input(Event::Mouse(mouse))) => return Ok(mouse),
×
451
                Ok(UnifiedEvent::Input(event)) => {
×
452
                    self.pending_event = Some(event);
×
453
                }
×
454
                Ok(UnifiedEvent::PtyWakeup(_)) => {}
×
455
                Ok(UnifiedEvent::AppExited(key)) => {
×
456
                    self.exited_windows.push(key);
×
457
                }
×
458
                Ok(UnifiedEvent::Signal) => self.signal_received = true,
×
459
                Ok(UnifiedEvent::Tick) => {}
×
460
                Err(_) => {
461
                    return Err(io::Error::new(
×
462
                        io::ErrorKind::BrokenPipe,
×
463
                        "event channel disconnected",
×
464
                    ));
×
465
                }
466
            }
467
        }
468
    }
×
469

470
    fn set_mouse_capture(&mut self, enabled: bool) -> io::Result<()> {
×
471
        if enabled {
×
472
            crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)
×
473
        } else {
474
            crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)
×
475
        }
476
    }
×
477

478
    /// Called by the runner each cycle to signal whether there's pending
479
    /// work (e.g. a countdown timer).  When true the profile stays at
480
    /// Streaming even if both `dirty_windows` and `last_event_at` are stale.
481
    fn set_pending_work(&mut self, pending: bool) {
×
482
        self.pending_work = pending;
×
483
    }
×
484

485
    fn set_max_sleep_duration(&mut self, duration: Option<Duration>) {
2✔
486
        self.max_sleep_duration = duration;
2✔
487
    }
2✔
488

489
    fn poll_interval(&self) -> Duration {
3✔
490
        let base = self.current_profile().poll_interval();
3✔
491
        match self.max_sleep_duration {
3✔
492
            Some(max_sleep) => base.min(max_sleep),
1✔
493
            None => {
494
                #[cfg(target_os = "windows")]
495
                {
496
                    // Windows ConPTY requires regular event loop ticks to prevent
497
                    // background PTY reader threads from stalling. When no explicit
498
                    // cap is set, clamp to WINDOWS_MAX_POLL_INTERVAL maximum.
499
                    base.min(term_wm_core::power_profile::WINDOWS_MAX_POLL_INTERVAL)
500
                }
501
                #[cfg(not(target_os = "windows"))]
502
                {
503
                    base
2✔
504
                }
505
            }
506
        }
507
    }
3✔
508

509
    fn current_profile(&self) -> PowerProfile {
8✔
510
        crate::power_profile::profile_from_activity(
8✔
511
            self.last_event_at,
8✔
512
            !self.dirty_windows.is_empty() || self.pending_work,
8✔
513
        )
514
    }
8✔
515

516
    fn take_exited_windows(&mut self) -> Vec<WindowKey> {
2✔
517
        std::mem::take(&mut self.exited_windows)
2✔
518
    }
2✔
519

520
    fn take_dirty_windows(&mut self) -> HashSet<WindowKey> {
3✔
521
        std::mem::take(&mut self.dirty_windows)
3✔
522
    }
3✔
523
}
524

525
impl Drop for UnifiedEventSource {
526
    fn drop(&mut self) {
10✔
527
        self.shutdown.store(true, Ordering::Release);
10✔
528
    }
10✔
529
}
530

531
#[cfg(test)]
532
mod tests {
533
    use super::*;
534
    use crate::events::{KeyCode, KeyKind, KeyModifiers};
535

536
    /// Input events drained by `drain_pending` must be preserved in
537
    /// `input_buffer` so `poll()/read()` can process every event.
538
    #[test]
539
    fn drain_pending_preserves_all_input_events() {
1✔
540
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
541
        let mut source = UnifiedEventSource {
1✔
542
            rx,
1✔
543
            tx: tx.clone(),
1✔
544
            _input_handle: std::thread::spawn(|| {}),
1✔
545
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
546
            dirty_windows: HashSet::new(),
1✔
547
            exited_windows: Vec::new(),
1✔
548
            pending_event: None,
1✔
549
            input_buffer: VecDeque::new(),
1✔
550
            signal_received: false,
551
            normalizer: KeyboardNormalizer::new(),
1✔
552
            last_event_at: None,
1✔
553
            frame_pacer: FramePacer::new(),
1✔
554
            pending_work: false,
555
            max_sleep_duration: None,
1✔
556
        };
557
        // Prevent the no-op handle from panicking on join in drop
558
        let dummy_handle = std::thread::spawn(|| {});
1✔
559
        source._input_handle = dummy_handle;
1✔
560

561
        // Send 10 input events into the channel
562
        for i in 0..10u8 {
10✔
563
            let evt = Event::Key(KeyEvent {
10✔
564
                code: KeyCode::Char(char::from(b'a' + i)),
10✔
565
                modifiers: KeyModifiers::NONE,
10✔
566
                kind: KeyKind::Press,
10✔
567
            });
10✔
568
            tx.send(UnifiedEvent::Input(evt)).unwrap();
10✔
569
        }
10✔
570
        // Also mix in some PtyWakeups (the reason drain_pending exists)
571
        for _ in 0..3 {
3✔
572
            tx.send(UnifiedEvent::PtyWakeup(WindowKey::default()))
3✔
573
                .unwrap();
3✔
574
        }
3✔
575

576
        // drain_pending must move all Input events into input_buffer
577
        source.drain_pending();
1✔
578

579
        assert_eq!(
1✔
580
            source.input_buffer.len(),
1✔
581
            10,
582
            "all 10 input events must be buffered, not dropped"
583
        );
584

585
        // verify ordering is preserved
586
        for (i, evt) in source.input_buffer.iter().enumerate() {
10✔
587
            let expected = char::from(b'a' + i as u8);
10✔
588
            match evt {
10✔
589
                Event::Key(k) => {
10✔
590
                    assert_eq!(
10✔
591
                        k.code,
592
                        KeyCode::Char(expected),
10✔
593
                        "event {} should be '{}'",
594
                        i,
595
                        expected
596
                    );
597
                }
598
                _ => panic!("expected Key event at position {}", i),
×
599
            }
600
        }
601

602
        // poll should report events available from buffer
603
        assert!(
1✔
604
            source.poll(Duration::ZERO).unwrap(),
1✔
605
            "poll must return true when buffer is non-empty"
606
        );
607

608
        // read should drain buffer in order
609
        for i in 0..10u8 {
10✔
610
            let evt = source.read().unwrap();
10✔
611
            let expected = char::from(b'a' + i);
10✔
612
            match evt {
10✔
613
                Event::Key(k) => assert_eq!(k.code, KeyCode::Char(expected)),
10✔
614
                _ => panic!("expected Key event"),
×
615
            }
616
        }
617

618
        // buffer should now be empty
619
        assert!(source.input_buffer.is_empty());
1✔
620
        assert!(
1✔
621
            !source.poll(Duration::ZERO).unwrap(),
1✔
622
            "poll must return false after buffer drained"
623
        );
624
    }
1✔
625

626
    /// `drain_pending` must filter out Release events through the
627
    /// normalizer, keeping Press and Repeat events in the buffer.
628
    #[test]
629
    fn drain_pending_filters_release_events() {
1✔
630
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
631
        let mut source = UnifiedEventSource {
1✔
632
            rx,
1✔
633
            tx: tx.clone(),
1✔
634
            _input_handle: std::thread::spawn(|| {}),
1✔
635
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
636
            dirty_windows: HashSet::new(),
1✔
637
            exited_windows: Vec::new(),
1✔
638
            pending_event: None,
1✔
639
            input_buffer: VecDeque::new(),
1✔
640
            signal_received: false,
641
            normalizer: KeyboardNormalizer::new(),
1✔
642
            last_event_at: None,
1✔
643
            frame_pacer: FramePacer::new(),
1✔
644
            pending_work: false,
645
            max_sleep_duration: None,
1✔
646
        };
647
        source._input_handle = std::thread::spawn(|| {});
1✔
648

649
        // Send: Press, Release, Repeat, Press
650
        // On non-Windows: Release filtered, 3 survive (Press, Repeat, Press)
651
        // On Windows: Release + Repeat filtered, 2 survive (Press, Press)
652
        for (i, kind) in [
4✔
653
            KeyKind::Press,
1✔
654
            KeyKind::Release,
1✔
655
            KeyKind::Repeat,
1✔
656
            KeyKind::Press,
1✔
657
        ]
1✔
658
        .into_iter()
1✔
659
        .enumerate()
1✔
660
        {
4✔
661
            let evt = Event::Key(KeyEvent {
4✔
662
                code: KeyCode::Char(char::from(b'a' + i as u8)),
4✔
663
                modifiers: KeyModifiers::NONE,
4✔
664
                kind,
4✔
665
            });
4✔
666
            tx.send(UnifiedEvent::Input(evt)).unwrap();
4✔
667
        }
4✔
668

669
        source.drain_pending();
1✔
670

671
        // Count surviving events — Release is always filtered, Repeat only on Windows
672
        let expected = if cfg!(windows) { 2 } else { 3 };
1✔
673
        assert_eq!(
1✔
674
            source.input_buffer.len(),
1✔
675
            expected,
676
            "Release must be filtered; on non-Windows Repeat survives"
677
        );
678

679
        // Verify Release event (index 1) is absent
680
        for evt in &source.input_buffer {
3✔
681
            if let Event::Key(k) = evt {
3✔
682
                assert_ne!(
3✔
683
                    k.kind,
684
                    KeyKind::Release,
685
                    "Release events must never survive normalization"
686
                );
NEW
687
            }
×
688
        }
689

690
        // Verify first and last events are the Press events
691
        let first = source.input_buffer.front().unwrap();
1✔
692
        let last = source.input_buffer.back().unwrap();
1✔
693
        if let (Event::Key(k1), Event::Key(k2)) = (first, last) {
1✔
694
            assert_eq!(k1.kind, KeyKind::Press);
1✔
695
            assert_eq!(k1.code, KeyCode::Char('a'));
1✔
696
            assert_eq!(k2.kind, KeyKind::Press);
1✔
697
            assert_eq!(k2.code, KeyCode::Char('d'));
1✔
698
        } else {
NEW
699
            panic!("expected Key events");
×
700
        }
701
    }
1✔
702

703
    /// `poll` must skip Release events that arrive via recv_timeout,
704
    /// continuing to wait until a valid event arrives or the deadline expires.
705
    #[test]
706
    fn poll_filters_release_events() {
1✔
707
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
708
        let mut source = UnifiedEventSource {
1✔
709
            rx,
1✔
710
            tx: tx.clone(),
1✔
711
            _input_handle: std::thread::spawn(|| {}),
1✔
712
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
713
            dirty_windows: HashSet::new(),
1✔
714
            exited_windows: Vec::new(),
1✔
715
            pending_event: None,
1✔
716
            input_buffer: VecDeque::new(),
1✔
717
            signal_received: false,
718
            normalizer: KeyboardNormalizer::new(),
1✔
719
            last_event_at: None,
1✔
720
            frame_pacer: FramePacer::new(),
1✔
721
            pending_work: false,
722
            max_sleep_duration: None,
1✔
723
        };
724
        source._input_handle = std::thread::spawn(|| {});
1✔
725

726
        // Send only Release events (filtered on all platforms)
727
        for _ in 0..3 {
3✔
728
            let evt = Event::Key(KeyEvent {
3✔
729
                code: KeyCode::Char('x'),
3✔
730
                modifiers: KeyModifiers::NONE,
3✔
731
                kind: KeyKind::Release,
3✔
732
            });
3✔
733
            tx.send(UnifiedEvent::Input(evt)).unwrap();
3✔
734
        }
3✔
735

736
        // poll with a short timeout — should consume the filtered events
737
        // and return Ok(false) since no valid event is available
738
        let result = source.poll(Duration::from_millis(50));
1✔
739
        assert!(
1✔
740
            !result.unwrap() || source.pending_event.is_none(),
1✔
741
            "poll must not set pending_event for filtered Release events"
742
        );
743
    }
1✔
744

745
    /// Dirty windows must be cleared after `poll()` returns `Ok(false)`,
746
    /// otherwise the power profile stays at `Streaming` (16ms) forever.
747
    #[test]
748
    fn dirty_windows_cleared_after_poll_ok_false() {
1✔
749
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
750
        let mut source = UnifiedEventSource {
1✔
751
            rx,
1✔
752
            tx: tx.clone(),
1✔
753
            _input_handle: std::thread::spawn(|| {}),
1✔
754
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
755
            dirty_windows: HashSet::new(),
1✔
756
            exited_windows: Vec::new(),
1✔
757
            pending_event: None,
1✔
758
            input_buffer: VecDeque::new(),
1✔
759
            signal_received: false,
760
            normalizer: KeyboardNormalizer::new(),
1✔
761
            last_event_at: None,
1✔
762
            frame_pacer: FramePacer::new(),
1✔
763
            pending_work: false,
764
            max_sleep_duration: None,
1✔
765
        };
766
        // Prevent the no-op handle from panicking on drop
767
        let dummy_handle = std::thread::spawn(|| {});
1✔
768
        source._input_handle = dummy_handle;
1✔
769

770
        // Baseline: no input, no dirty → PowerSaver
771
        assert_eq!(source.current_profile(), PowerProfile::PowerSaver);
1✔
772

773
        // Send a PtyWakeup — drain_pending will pick it up inside poll()
774
        tx.send(UnifiedEvent::PtyWakeup(WindowKey::default()))
1✔
775
            .unwrap();
1✔
776

777
        // poll() should drain the PtyWakeup, arm the 16ms frame pacer, then
778
        // let it expire and return Ok(false) with dirty_windows still set.
779
        assert!(
1✔
780
            !source.poll(Duration::from_secs(1)).unwrap(),
1✔
781
            "poll must return Ok(false) after PtyWakeup expiry"
782
        );
783

784
        // After poll returns, dirty_windows must contain the key
785
        // (coalesce arms the timer but does NOT clear dirty_windows on expiry).
786
        assert!(
1✔
787
            !source.take_dirty_windows().is_empty(),
1✔
788
            "dirty_windows must still contain the key after poll"
789
        );
790

791
        // After taking the dirty windows, profile returns to PowerSaver
792
        // (no input activity, no dirty windows).
793
        assert_eq!(
1✔
794
            source.current_profile(),
1✔
795
            PowerProfile::PowerSaver,
796
            "profile must return to PowerSaver after dirty_windows consumed"
797
        );
798
    }
1✔
799

800
    /// Verify that a non-empty dirty_windows causes Streaming profile,
801
    /// confirming the mechanism the bug fix relies on.
802
    #[test]
803
    fn dirty_windows_causes_streaming_profile() {
1✔
804
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
805
        let mut set = HashSet::new();
1✔
806
        set.insert(WindowKey::default());
1✔
807
        let source = UnifiedEventSource {
1✔
808
            rx,
1✔
809
            tx: _tx,
1✔
810
            _input_handle: std::thread::spawn(|| {}),
1✔
811
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
812
            dirty_windows: set,
1✔
813
            exited_windows: Vec::new(),
1✔
814
            pending_event: None,
1✔
815
            input_buffer: VecDeque::new(),
1✔
816
            signal_received: false,
817
            normalizer: KeyboardNormalizer::new(),
1✔
818
            last_event_at: None,
1✔
819
            frame_pacer: FramePacer::new(),
1✔
820
            pending_work: false,
821
            max_sleep_duration: None,
1✔
822
        };
823
        assert_eq!(
1✔
824
            source.current_profile(),
1✔
825
            PowerProfile::Streaming,
826
            "dirty_windows must elevate profile to Streaming"
827
        );
828
    }
1✔
829

830
    #[test]
831
    fn pending_work_causes_streaming_profile() {
1✔
832
        let (tx1, rx1) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
833
        let source = UnifiedEventSource {
1✔
834
            rx: rx1,
1✔
835
            tx: tx1,
1✔
836
            _input_handle: std::thread::spawn(|| {}),
1✔
837
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
838
            dirty_windows: HashSet::new(),
1✔
839
            exited_windows: Vec::new(),
1✔
840
            pending_event: None,
1✔
841
            input_buffer: VecDeque::new(),
1✔
842
            signal_received: false,
843
            normalizer: KeyboardNormalizer::new(),
1✔
844
            last_event_at: None,
1✔
845
            frame_pacer: FramePacer::new(),
1✔
846
            pending_work: true,
847
            max_sleep_duration: None,
1✔
848
        };
849
        assert_eq!(
1✔
850
            source.current_profile(),
1✔
851
            PowerProfile::Streaming,
852
            "pending_work must elevate profile to Streaming even without dirty_windows"
853
        );
854
        // Also verify that stale last_event_at + pending_work still gives Streaming
855
        let (tx2, rx2) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
856
        let stale = Instant::now().checked_sub(Duration::from_secs(3600));
1✔
857
        let source2 = UnifiedEventSource {
1✔
858
            rx: rx2,
1✔
859
            tx: tx2,
1✔
860
            _input_handle: std::thread::spawn(|| {}),
1✔
861
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
862
            dirty_windows: HashSet::new(),
1✔
863
            exited_windows: Vec::new(),
1✔
864
            pending_event: None,
1✔
865
            input_buffer: VecDeque::new(),
1✔
866
            signal_received: false,
867
            normalizer: KeyboardNormalizer::new(),
1✔
868
            last_event_at: stale,
1✔
869
            frame_pacer: FramePacer::new(),
1✔
870
            pending_work: true,
871
            max_sleep_duration: None,
1✔
872
        };
873
        assert_eq!(
1✔
874
            source2.current_profile(),
1✔
875
            PowerProfile::Streaming,
876
            "pending_work must keep Streaming even with stale last_event_at"
877
        );
878
    }
1✔
879

880
    /// Regression: `take_exited_windows` must be reachable through the
881
    /// `EventSource` trait so that generic runner code (`D: EventSource`)
882
    /// actually gets the accumulated exit keys. Before the fix the method
883
    /// was only inherent — the trait override was missing, and the default
884
    /// no-op impl silently returned an empty vec, so exited windows never
885
    /// closed.
886
    #[test]
887
    fn take_exited_windows_returns_accumulated_keys_through_trait() {
1✔
888
        use super::EventSource;
889
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
890
        let key = WindowKey::default();
1✔
891
        let mut source = UnifiedEventSource {
1✔
892
            rx,
1✔
893
            tx: _tx,
1✔
894
            _input_handle: std::thread::spawn(|| {}),
1✔
895
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
896
            dirty_windows: HashSet::new(),
1✔
897
            exited_windows: vec![key],
1✔
898
            pending_event: None,
1✔
899
            input_buffer: VecDeque::new(),
1✔
900
            signal_received: false,
901
            normalizer: KeyboardNormalizer::new(),
1✔
902
            last_event_at: None,
1✔
903
            frame_pacer: FramePacer::new(),
1✔
904
            pending_work: false,
905
            max_sleep_duration: None,
1✔
906
        };
907
        let dummy_handle = std::thread::spawn(|| {});
1✔
908
        source._input_handle = dummy_handle;
1✔
909

910
        // Call through the trait, not an inherent method. Would return
911
        // Vec::new() if the trait override were missing.
912
        let exited = EventSource::take_exited_windows(&mut source);
1✔
913
        assert_eq!(exited, vec![key], "must return the pre-populated key");
1✔
914

915
        let again = EventSource::take_exited_windows(&mut source);
1✔
916
        assert!(again.is_empty(), "second call must drain");
1✔
917
    }
1✔
918

919
    /// Regression: `take_dirty_windows` must be reachable through the
920
    /// `EventSource` trait so that generic runner code (`D: EventSource`)
921
    /// actually consumes accumulated dirty keys.  Without the trait
922
    /// override the default no-op impl would silently return an empty
923
    /// set, leaving dirty_windows accumulated forever.
924
    #[test]
925
    fn take_dirty_windows_returns_accumulated_keys_through_trait() {
1✔
926
        use super::EventSource;
927
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
928
        let key = WindowKey::default();
1✔
929
        let mut set = HashSet::new();
1✔
930
        set.insert(key);
1✔
931
        let mut source = UnifiedEventSource {
1✔
932
            rx,
1✔
933
            tx: _tx,
1✔
934
            _input_handle: std::thread::spawn(|| {}),
1✔
935
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
936
            dirty_windows: set,
1✔
937
            exited_windows: Vec::new(),
1✔
938
            pending_event: None,
1✔
939
            input_buffer: VecDeque::new(),
1✔
940
            signal_received: false,
941
            normalizer: KeyboardNormalizer::new(),
1✔
942
            last_event_at: None,
1✔
943
            frame_pacer: FramePacer::new(),
1✔
944
            pending_work: false,
945
            max_sleep_duration: None,
1✔
946
        };
947
        let dummy_handle = std::thread::spawn(|| {});
1✔
948
        source._input_handle = dummy_handle;
1✔
949

950
        // Call through the trait, not an inherent method. Would return
951
        // an empty set if the trait override were missing.
952
        let taken = EventSource::take_dirty_windows(&mut source);
1✔
953
        assert_eq!(taken.len(), 1, "must return the pre-populated key");
1✔
954
        assert!(taken.contains(&key), "must contain the dirty key");
1✔
955

956
        let again = EventSource::take_dirty_windows(&mut source);
1✔
957
        assert!(again.is_empty(), "second call must drain");
1✔
958
    }
1✔
959

960
    #[test]
961
    fn poll_interval_clamped_by_max_sleep_duration() {
1✔
962
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
963
        let mut source = UnifiedEventSource {
1✔
964
            rx,
1✔
965
            tx: _tx,
1✔
966
            _input_handle: std::thread::spawn(|| {}),
1✔
967
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
968
            dirty_windows: HashSet::new(),
1✔
969
            exited_windows: Vec::new(),
1✔
970
            pending_event: None,
1✔
971
            input_buffer: VecDeque::new(),
1✔
972
            signal_received: false,
973
            normalizer: KeyboardNormalizer::new(),
1✔
974
            last_event_at: None,
1✔
975
            frame_pacer: FramePacer::new(),
1✔
976
            pending_work: false,
977
            max_sleep_duration: None,
1✔
978
        };
979
        let dummy = std::thread::spawn(|| {});
1✔
980
        source._input_handle = dummy;
1✔
981

982
        #[cfg(not(target_os = "windows"))]
983
        assert_eq!(
1✔
984
            source.poll_interval(),
1✔
985
            PowerProfile::PowerSaver.poll_interval()
1✔
986
        );
987
        #[cfg(target_os = "windows")]
988
        assert_eq!(
989
            source.poll_interval(),
990
            term_wm_core::power_profile::WINDOWS_MAX_POLL_INTERVAL
991
        );
992

993
        source.set_max_sleep_duration(Some(Duration::from_millis(100)));
1✔
994
        assert_eq!(source.poll_interval(), Duration::from_millis(100));
1✔
995

996
        source.set_max_sleep_duration(None);
1✔
997
        #[cfg(not(target_os = "windows"))]
998
        assert_eq!(
1✔
999
            source.poll_interval(),
1✔
1000
            PowerProfile::PowerSaver.poll_interval()
1✔
1001
        );
1002
        #[cfg(target_os = "windows")]
1003
        assert_eq!(
1004
            source.poll_interval(),
1005
            term_wm_core::power_profile::WINDOWS_MAX_POLL_INTERVAL
1006
        );
1007
    }
1✔
1008
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc