Skip to main content

omp_tui/
pump.rs

1//! Terminal event actor: one async task owns the input decoder and turns
2//! raw terminal bytes, debug injections, and decoder deadlines into a
3//! single mailbox of fully decoded [`TerminalEvent`]s.
4//!
5//! The actor `select!`s over three async sources — the byte source, the
6//! control channel, and the partial-escape deadline — plus, on Unix, the
7//! SIGWINCH self-pipe. Nothing here or in any host polls with a timeout:
8//!
9//! - [`crate::Terminal::next`] awaits the mailbox; resize rides a
10//!   `tokio::sync::watch` side channel so its biased `select!` observes a
11//!   resize before any backlog of queued input.
12//! - The `OMP_TUI_DEBUG` server has ONE ingress: it queues every debug action
13//!   on the control channel ([`send_event`], [`inject_bytes`]), and the actor
14//!   emits them into the mailbox in send order — injected raw bytes decode
15//!   before any later action, so acknowledged actions are ordering barriers.
16//! - Keymap edits arrive as actor commands ([`Pump::set_keymap`]) and apply
17//!   before the next decoded chord.
18//!
19//! The byte source is an [`AsyncFd`] wherever the platform can poll the
20//! terminal handle (Linux and other non-macOS Unix, plus every pipe or pty
21//! in tests); macOS `/dev/tty` and Windows `CONIN$` are not readiness-
22//! pollable, so those bridge through a minimal reader thread whose only job
23//! is `read` → flume. The actor is per-[`crate::Terminal`]: entry spawns it
24//! seeded with bytes preserved by capability negotiation, and
25//! [`crate::Terminal::leave`] stops it before the teardown drain reclaims
26//! the descriptor.
27
28use std::{fs::File, io};
29
30use parking_lot::Mutex;
31
32use crate::input::{InputDecoder, InputEvent, Keymap};
33
34/// One decoded terminal event.
35///
36/// Real input, debug-injected input, and debug queries share a single mailbox
37/// in arrival order. Pure data — the `OMP_TUI_DEBUG` protocol serializes it
38/// directly.
39#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub enum TerminalEvent {
41	/// A decoded input event — key, mouse, paste, focus, or a terminal
42	/// response the host forwards to [`crate::Terminal::handle_input_event`].
43	Input(InputEvent),
44	/// Terminal geometry may have changed; resolve it with
45	/// [`crate::Terminal::take_resize`]. Delivered ahead of queued input
46	/// through the resize watch in [`crate::Terminal::next`].
47	Resize,
48	/// A debug-protocol query routed through the event loop, in order with
49	/// injected and real input. [`crate::Terminal::next`] answers the
50	/// terminal-owned ops itself; retained-tree ops reach the host, which
51	/// answers via [`crate::respond_debug_query`] (hosts without a retained
52	/// tree ignore them and the server times the request out).
53	Debug(DebugQuery),
54	/// The terminal input closed or failed; no more input will arrive.
55	/// [`crate::Terminal::next`] surfaces it as an error.
56	Closed,
57}
58
59/// One correlated debug query routed through the event loop.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct DebugQuery {
62	/// Server-side correlation id for [`crate::App`]'s reply.
63	pub id: u64,
64	/// The queried state.
65	pub op: DebugOp,
66}
67
68/// Debug-protocol ops carried by [`TerminalEvent::Debug`].
69#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub enum DebugOp {
71	/// Viewport, document, and overlay summary; answered by the terminal
72	/// from the renderer's published snapshot.
73	Info,
74	/// Visible viewport as text; answered by the terminal from the
75	/// renderer's published snapshot.
76	Text,
77	/// Re-read tty geometry; the terminal emulates a SIGWINCH so the
78	/// normal resize flow runs.
79	Resize,
80	/// Quit request; the terminal acknowledges and emits `C-c`, the
81	/// conventional quit chord.
82	Quit,
83	/// Full document frame as text rows (retained hosts).
84	Frame,
85	/// Component tree with kinds, ids, rectangles, and focus (retained
86	/// hosts).
87	Tree,
88	/// [`crate::Ui::values`] of the base tree (retained hosts).
89	Values,
90}
91/// One command for the event actor.
92enum Ctl {
93	/// A terminal event to emit in ingress order — debug-injected input and
94	/// correlated debug queries. The actor flushes decoded input queued
95	/// ahead of it first, so a query can never overtake earlier injected
96	/// bytes.
97	Event(TerminalEvent),
98	/// Raw bytes to run through the live decoder.
99	Bytes(Vec<u8>),
100	/// Replace the chord keymap.
101	Keymap(Keymap),
102}
103
104/// The single debug ingress: every `OMP_TUI_DEBUG` action enters the actor
105/// through this control channel and is emitted into the mailbox in send
106/// order, so acknowledged actions are ordering barriers for later ones.
107static SHARED_CTL: Mutex<Option<flume::Sender<Ctl>>> = Mutex::new(None);
108
109/// Queues one event on the active actor's ingress. `false` when no
110/// terminal is live, in which case nothing would consume it.
111pub fn send_event(event: TerminalEvent) -> bool {
112	send_ctl(Ctl::Event(event))
113}
114
115/// Feeds debug-injected raw bytes through the active actor's decoder; the
116/// decode happens before any later ingress action is emitted.
117pub fn inject_bytes(bytes: Vec<u8>) -> bool {
118	send_ctl(Ctl::Bytes(bytes))
119}
120
121fn send_ctl(ctl: Ctl) -> bool {
122	let sender = SHARED_CTL.lock().clone();
123	sender.is_some_and(|sender| sender.send(ctl).is_ok())
124}
125
126/// Installs a bare ingress so debug-server tests can exercise the query
127/// path without a live terminal; the returned receiver yields the events
128/// the actor would emit.
129#[cfg(test)]
130pub fn publish_ingress_for_test() -> flume::Receiver<TerminalEvent> {
131	let (ctl_tx, ctl_rx) = flume::unbounded();
132	let (event_tx, event_rx) = flume::unbounded();
133	*SHARED_CTL.lock() = Some(ctl_tx);
134	std::thread::spawn(move || {
135		while let Ok(ctl) = ctl_rx.recv() {
136			if let Ctl::Event(event) = ctl
137				&& event_tx.send(event).is_err()
138			{
139				return;
140			}
141		}
142	});
143	event_rx
144}
145
146/// Handle to a running event actor; stopping is idempotent and dropping
147/// stops it.
148pub struct Pump {
149	task:   tokio::task::JoinHandle<()>,
150	bridge: Option<Bridge>,
151	ctl:    flume::Sender<Ctl>,
152}
153
154/// A reader thread bridging a non-pollable input handle into the actor.
155struct Bridge {
156	stop:   std::sync::Arc<std::sync::atomic::AtomicBool>,
157	worker: Option<std::thread::JoinHandle<()>>,
158}
159
160impl Pump {
161	/// Publishes this actor's ingress for the `OMP_TUI_DEBUG` server;
162	/// entry-only, so test terminals stay private.
163	pub(crate) fn publish(&self) {
164		*SHARED_CTL.lock() = Some(self.ctl.clone());
165	}
166
167	/// Replaces the decoder's chord keymap; applies before the next decoded
168	/// chord.
169	pub(crate) fn set_keymap(&self, keymap: Keymap) {
170		let _ = self.ctl.send(Ctl::Keymap(keymap));
171	}
172
173	/// Stops the actor (and any bridge thread) and releases the input
174	/// handle.
175	///
176	/// Called by [`crate::Terminal::leave`] before the teardown drain reads
177	/// the descriptor directly.
178	pub(crate) fn stop(&mut self) {
179		self.task.abort();
180		if let Some(bridge) = self.bridge.as_mut() {
181			bridge
182				.stop
183				.store(true, std::sync::atomic::Ordering::Release);
184			if let Some(worker) = bridge.worker.take() {
185				let _ = worker.join();
186			}
187		}
188	}
189}
190
191impl Drop for Pump {
192	fn drop(&mut self) {
193		self.stop();
194	}
195}
196
197/// Everything the spawned actor hands back to its owning terminal.
198pub struct PumpChannels {
199	/// The running actor.
200	pub pump:   Pump,
201	/// Sole receiver of decoded terminal events.
202	pub events: flume::Receiver<TerminalEvent>,
203	/// Resize side channel; the value is a monotonically increasing wake
204	/// count. Never fires on Windows, where hosts poll geometry instead.
205	pub resize: tokio::sync::watch::Receiver<u64>,
206}
207
208/// Raw bytes flowing into the actor.
209enum ByteSource {
210	/// Readiness-pollable handle (non-macOS Unix terminals; test pipes and
211	/// ptys everywhere on Unix).
212	#[cfg(unix)]
213	Fd(tokio::io::unix::AsyncFd<std::os::fd::OwnedFd>),
214	/// Reader-thread bridge for handles the OS cannot poll.
215	Thread(flume::Receiver<Vec<u8>>),
216}
217
218impl ByteSource {
219	/// Waits for the next chunk; `Ok(None)` means the handle closed.
220	///
221	/// Cancel-safe: no chunk is lost when the surrounding `select!` takes
222	/// another branch first.
223	async fn next(&mut self) -> io::Result<Option<Vec<u8>>> {
224		match self {
225			#[cfg(unix)]
226			Self::Fd(fd) => loop {
227				let mut guard = fd.readable().await?;
228				let mut bytes = [0_u8; 4096];
229				match guard.try_io(|fd| read_fd(fd.get_ref(), &mut bytes)) {
230					Ok(Ok(0)) => return Ok(None),
231					Ok(Ok(read)) => return Ok(Some(bytes[..read].to_vec())),
232					Ok(Err(error)) => return Err(error),
233					Err(_) => {},
234				}
235			},
236			Self::Thread(rx) => Ok(rx.recv_async().await.ok()),
237		}
238	}
239}
240
241/// Reads once from a raw descriptor, retrying `EINTR`.
242#[cfg(unix)]
243fn read_fd(fd: &std::os::fd::OwnedFd, bytes: &mut [u8]) -> io::Result<usize> {
244	use std::os::fd::AsRawFd as _;
245	loop {
246		// SAFETY: `fd` is open for reading and `bytes` is a writable slice.
247		let read = unsafe { nix::libc::read(fd.as_raw_fd(), bytes.as_mut_ptr().cast(), bytes.len()) };
248		if read >= 0 {
249			return Ok(read as usize);
250		}
251		let error = io::Error::last_os_error();
252		if error.kind() != io::ErrorKind::Interrupted {
253			return Err(error);
254		}
255	}
256}
257
258/// Spawns the event actor over `input` with `decoder`, seeded with
259/// `preserved` bytes from capability negotiation, watching `resize` (a
260/// duplicate of the SIGWINCH self-pipe read end) when given.
261///
262/// # Panics
263///
264/// Panics outside a tokio runtime; the terminal event loop is async.
265pub fn spawn(
266	input: Input,
267	mut decoder: InputDecoder,
268	preserved: &[u8],
269	#[cfg_attr(windows, expect(unused_variables, reason = "windows polls geometry instead"))]
270	resize: Option<ResizeFd>,
271) -> io::Result<PumpChannels> {
272	let (events_tx, events_rx) = flume::unbounded();
273	let (resize_tx, resize_rx) = tokio::sync::watch::channel(0_u64);
274	let (ctl_tx, ctl_rx) = flume::unbounded();
275
276	let (source, bridge) = input.into_source()?;
277	#[cfg(unix)]
278	let resize = resize.map(tokio::io::unix::AsyncFd::new).transpose()?;
279	#[cfg(windows)]
280	let resize = ();
281
282	let mut events = Vec::new();
283	decoder.feed(preserved, std::time::Instant::now(), &mut events);
284
285	let task = tokio::spawn(actor(source, decoder, events, events_tx, ctl_rx, resize, resize_tx));
286	Ok(PumpChannels {
287		pump:   Pump { task, bridge, ctl: ctl_tx },
288		events: events_rx,
289		resize: resize_rx,
290	})
291}
292
293/// The SIGWINCH self-pipe read end handed to the actor.
294#[cfg(unix)]
295pub type ResizeFd = std::os::fd::OwnedFd;
296#[cfg(windows)]
297pub(crate) type ResizeFd = std::convert::Infallible;
298
299/// The input handle the actor reads, chosen by the terminal per platform.
300pub enum Input {
301	/// A readiness-pollable Unix handle (terminal, pipe, or pty).
302	#[cfg(unix)]
303	#[cfg_attr(
304		target_os = "macos",
305		allow(dead_code, reason = "macOS terminals bridge; tests spawn pollable pipe sources")
306	)]
307	Pollable(File),
308	/// A handle that needs a reader-thread bridge (macOS `/dev/tty`,
309	/// Windows `CONIN$`).
310	Bridged(File),
311}
312
313impl Input {
314	fn into_source(self) -> io::Result<(ByteSource, Option<Bridge>)> {
315		match self {
316			#[cfg(unix)]
317			Self::Pollable(file) => {
318				use std::os::fd::AsRawFd as _;
319				// SAFETY: fcntl F_SETFL with O_NONBLOCK on an owned handle.
320				if unsafe {
321					nix::libc::fcntl(file.as_raw_fd(), nix::libc::F_SETFL, nix::libc::O_NONBLOCK)
322				} < 0
323				{
324					return Err(io::Error::last_os_error());
325				}
326				let fd = tokio::io::unix::AsyncFd::new(std::os::fd::OwnedFd::from(file))?;
327				Ok((ByteSource::Fd(fd), None))
328			},
329			Self::Bridged(file) => {
330				let (tx, rx) = flume::unbounded();
331				let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
332				let bridge_stop = std::sync::Arc::clone(&stop);
333				let worker = std::thread::Builder::new()
334					.name("omp-tui-input".into())
335					.spawn(move || bridge_loop(file, &tx, &bridge_stop))?;
336				Ok((ByteSource::Thread(rx), Some(Bridge { stop, worker: Some(worker) })))
337			},
338		}
339	}
340}
341
342/// Blocking bridge for non-pollable handles: `read` → flume until EOF,
343/// error, or stop. The 50ms cadence exists only to observe `stop`; it never
344/// delays delivery of ready bytes.
345fn bridge_loop(input: File, tx: &flume::Sender<Vec<u8>>, stop: &std::sync::atomic::AtomicBool) {
346	use std::sync::atomic::Ordering;
347	let mut bytes = [0_u8; 4096];
348	#[cfg(unix)]
349	{
350		use std::{io::Read as _, os::fd::AsRawFd as _};
351		let mut input = input;
352		let mut descriptor =
353			nix::libc::pollfd { fd: input.as_raw_fd(), events: nix::libc::POLLIN, revents: 0 };
354		while !stop.load(Ordering::Acquire) {
355			descriptor.revents = 0;
356			// SAFETY: single pollfd, valid for the call.
357			let ready = unsafe { nix::libc::poll(&mut descriptor, 1, 50) };
358			if ready < 0 {
359				if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
360					continue;
361				}
362				return;
363			}
364			if ready == 0 {
365				continue;
366			}
367			if descriptor.revents & (nix::libc::POLLERR | nix::libc::POLLNVAL) != 0 {
368				return;
369			}
370			match input.read(&mut bytes) {
371				Ok(0) => return,
372				Ok(read) => {
373					if tx.send(bytes[..read].to_vec()).is_err() {
374						return;
375					}
376				},
377				Err(error)
378					if matches!(
379						error.kind(),
380						io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock
381					) => {},
382				Err(_) => return,
383			}
384		}
385	}
386	#[cfg(windows)]
387	{
388		use std::{io::Read as _, os::windows::io::AsRawHandle as _};
389		let mut input = input;
390		let handle = input.as_raw_handle();
391		while !stop.load(Ordering::Acquire) {
392			let ready =
393				unsafe { windows_sys::Win32::System::Threading::WaitForSingleObject(handle, 50) };
394			if ready == windows_sys::Win32::Foundation::WAIT_TIMEOUT {
395				continue;
396			}
397			if ready != windows_sys::Win32::Foundation::WAIT_OBJECT_0 {
398				return;
399			}
400			match input.read(&mut bytes) {
401				Ok(0) => return,
402				Ok(read) => {
403					if tx.send(bytes[..read].to_vec()).is_err() {
404						return;
405					}
406				},
407				Err(_) => return,
408			}
409		}
410	}
411}
412
413/// The decode loop: byte source, control channel, decoder deadline, and
414/// resize pipe merged into one ordered stream of [`TerminalEvent`]s.
415async fn actor(
416	mut source: ByteSource,
417	mut decoder: InputDecoder,
418	mut events: Vec<InputEvent>,
419	events_tx: flume::Sender<TerminalEvent>,
420	ctl_rx: flume::Receiver<Ctl>,
421	#[cfg(unix)] resize: Option<tokio::io::unix::AsyncFd<std::os::fd::OwnedFd>>,
422	#[cfg(windows)] resize: (),
423	resize_tx: tokio::sync::watch::Sender<u64>,
424) {
425	// The resize sender lives for the actor's lifetime so
426	// `watch::Receiver::changed` pends instead of erroring where resize
427	// wakes never fire.
428	let mut resize_wakes = 0_u64;
429	loop {
430		for event in std::mem::take(&mut events) {
431			if events_tx.send(TerminalEvent::Input(event)).is_err() {
432				return;
433			}
434		}
435		let wake = decoder.deadline().map(tokio::time::Instant::from_std);
436		tokio::select! {
437			// Resize outranks everything: a replenished debug or input
438			// backlog must never keep the watch from firing.
439			biased;
440			() = resize_readable(#[cfg(unix)] resize.as_ref()) => {
441				resize_wakes += 1;
442				if resize_tx.send(resize_wakes).is_err() {
443					return;
444				}
445			},
446			chunk = source.next() => if let Ok(Some(bytes)) = chunk {
447						decoder.feed(&bytes, std::time::Instant::now(), &mut events);
448					} else {
449						let _ = events_tx.send(TerminalEvent::Closed);
450						return;
451					},
452			// One ingress action per iteration: flume preserves send order
453			// and the loop-top flush keeps decoded input ahead of later
454			// actions, while resize stays reachable between actions.
455			ctl = ctl_rx.recv_async() => {
456				let Ok(ctl) = ctl else {
457					// The owning terminal is gone; nothing to serve.
458					return;
459				};
460				if !apply_ctl(ctl, &mut decoder, &mut events, &events_tx) {
461					return;
462				}
463			},
464			() = deadline(wake) => {
465				decoder.tick(std::time::Instant::now(), &mut events);
466			},
467		}
468	}
469}
470
471/// Applies one ingress action in order: raw bytes advance the decoder,
472/// events flush decoded input queued ahead of them and then emit, keymap
473/// swaps apply immediately. `false` once the mailbox is gone.
474fn apply_ctl(
475	ctl: Ctl,
476	decoder: &mut InputDecoder,
477	events: &mut Vec<InputEvent>,
478	events_tx: &flume::Sender<TerminalEvent>,
479) -> bool {
480	match ctl {
481		Ctl::Bytes(bytes) => {
482			decoder.feed(&bytes, std::time::Instant::now(), events);
483			true
484		},
485		Ctl::Event(event) => {
486			for decoded in events.drain(..) {
487				if events_tx.send(TerminalEvent::Input(decoded)).is_err() {
488					return false;
489				}
490			}
491			events_tx.send(event).is_ok()
492		},
493		Ctl::Keymap(keymap) => {
494			*decoder.keymap_mut() = keymap;
495			true
496		},
497	}
498}
499
500/// Resolves once the resize self-pipe is readable, after draining it; never
501/// resolves without a pipe.
502#[cfg(unix)]
503async fn resize_readable(resize: Option<&tokio::io::unix::AsyncFd<std::os::fd::OwnedFd>>) -> () {
504	let Some(fd) = resize else {
505		return std::future::pending().await;
506	};
507	loop {
508		let Ok(mut guard) = fd.readable().await else {
509			return std::future::pending().await;
510		};
511		let mut bytes = [0_u8; 128];
512		match guard.try_io(|fd| read_fd(fd.get_ref(), &mut bytes)) {
513			Ok(Ok(0)) => return std::future::pending().await,
514			Ok(Ok(_)) => return,
515			Ok(Err(_)) => return std::future::pending().await,
516			Err(_) => {},
517		}
518	}
519}
520
521#[cfg(windows)]
522async fn resize_readable(_resize: ()) {
523	std::future::pending().await
524}
525
526/// Sleeps until `at`; `None` disables the branch.
527async fn deadline(at: Option<tokio::time::Instant>) {
528	match at {
529		Some(at) => tokio::time::sleep_until(at).await,
530		None => std::future::pending().await,
531	}
532}