Skip to main content

omp_tui/
debug.rs

1//! `OMP_TUI_DEBUG` introspection socket for terminal hosts.
2//!
3//! Setting `OMP_TUI_DEBUG=<unix-socket-path>` makes [`crate::Terminal`]
4//! entry start a server thread that binds the socket and answers one JSON
5//! request per line. The wire speaks [`crate::TerminalEvent`] directly:
6//!
7//! - Input ops (`keys`, `paste`, `mouse`, `event`, `bytes`) become mailbox
8//!   events — decoded ones are sent verbatim, raw `bytes` run through the live
9//!   decoder — so the host observes debug input exactly like terminal input, in
10//!   arrival order.
11//! - Screen ops (`text`, `info`) answer from the snapshot the renderer
12//!   publishes on every paint ([`publish_screen`]).
13//! - `resize` emulates a SIGWINCH so every host's normal geometry recheck
14//!   fires; `quit` injects `C-c`, the conventional quit chord.
15//! - Retained-state ops (`frame`, `tree`, `values`) ride the mailbox as
16//!   [`crate::TerminalEvent::Debug`] queries; [`crate::App`] answers them via
17//!   [`respond_debug_query`], hosts without a retained tree ignore them and the
18//!   server times the request out.
19//!
20//! Harnesses pair the socket with `OMP_TTY`: the pty master captures the
21//! exact byte stream a terminal would see, while this socket provides
22//! structured introspection and input injection.
23//!
24//! Requests are single-line JSON objects selected by `"op"`; every response
25//! is one JSON line with `"ok"`:
26//!
27//! | op | fields | effect |
28//! | --- | --- | --- |
29//! | `info` | | viewport, document, overlay summary |
30//! | `text` | | visible viewport as text (last painted screen) |
31//! | `frame` | | full document frame as text rows (retained hosts) |
32//! | `tree` | | component tree with kinds, ids, rects, focus (retained) |
33//! | `values` | | [`crate::Ui::values`] of the base tree (retained) |
34//! | `keys` | `keys` | inject decoded keys, e.g. `"tab C-a enter 'text'"` |
35//! | `event` | `event` | inject one serialized [`crate::TerminalEvent`] |
36//! | `bytes` | `data` | feed raw bytes through the input decoder |
37//! | `paste` | `text` | inject a bracketed paste |
38//! | `mouse` | `x`,`y`,`action`[,`button`] | inject an SGR-level gesture |
39//! | `resize` | | re-read tty geometry (pair with `TIOCSWINSZ`) |
40//! | `quit` | | inject the conventional `C-c` quit chord |
41
42use std::sync::{
43	LazyLock,
44	atomic::{AtomicBool, Ordering},
45};
46
47use parking_lot::Mutex;
48
49use crate::{
50	input::{InputEvent, Key, Mouse, MouseButton, MouseReport},
51	pump::{DebugOp, DebugQuery, TerminalEvent},
52};
53
54/// Environment variable naming the debug socket path.
55pub const DEBUG_ENV: &str = "OMP_TUI_DEBUG";
56
57/// Latest painted viewport, published by the renderer whenever the socket
58/// is enabled. `None` until the first paint.
59static SCREEN: Mutex<Option<ScreenSnapshot>> = Mutex::new(None);
60
61/// Host responses to in-flight [`DebugQuery`]s, keyed by query id.
62static RESPONSES: Mutex<Option<flume::Sender<(u64, serde_json::Value)>>> = Mutex::new(None);
63
64/// Whether `OMP_TUI_DEBUG` names a socket path (checked once).
65pub fn enabled() -> bool {
66	static ENABLED: LazyLock<bool> =
67		LazyLock::new(|| std::env::var_os(DEBUG_ENV).is_some_and(|value| !value.is_empty()));
68	*ENABLED
69}
70
71/// Whether the renderer should publish paint snapshots.
72pub fn publishing() -> bool {
73	enabled()
74}
75
76/// Answers one [`crate::TerminalEvent::Debug`] query; retained hosts call
77/// this with the JSON payload for the query's id. Late or duplicate
78/// responses are dropped.
79pub fn respond_debug_query(id: u64, response: serde_json::Value) {
80	let sender = RESPONSES.lock().clone();
81	if let Some(sender) = sender {
82		let _ = sender.send((id, response));
83	}
84}
85
86/// Replaces the published screen snapshot after a paint.
87pub fn publish_screen(snapshot: ScreenSnapshot) {
88	*SCREEN.lock() = Some(snapshot);
89}
90
91/// Clones the latest published screen snapshot.
92pub fn screen_snapshot() -> Option<ScreenSnapshot> {
93	SCREEN.lock().clone()
94}
95
96/// What the terminal currently shows, as published by the last paint.
97#[derive(Clone)]
98pub struct ScreenSnapshot {
99	/// Right-trimmed visible text, one string per viewport row.
100	pub lines:      Vec<String>,
101	/// Visible hardware cursor as (row, column), when placed.
102	pub cursor:     Option<(u16, u16)>,
103	/// Document row shown at the viewport top.
104	pub window_top: u16,
105	/// Viewport width in cells.
106	pub cols:       u16,
107	/// Viewport height in rows.
108	pub rows:       u16,
109	/// Full document height in rows.
110	pub doc_height: u16,
111	/// Whether viewport layers were composited into this paint.
112	pub overlay:    bool,
113}
114
115/// Serves one request the server can acknowledge without host state —
116/// injections, which become mailbox events so hosts observe them like
117/// terminal input. Every query returns `Err` with its [`DebugOp`]: the
118/// server sends it through the same mailbox and correlates the reply.
119fn direct_response(request: DebugRequest) -> Result<serde_json::Value, DebugOp> {
120	use serde_json::json;
121	Ok(match request {
122		DebugRequest::Info => return Err(DebugOp::Info),
123		DebugRequest::Text => return Err(DebugOp::Text),
124		DebugRequest::Frame => return Err(DebugOp::Frame),
125		DebugRequest::Tree => return Err(DebugOp::Tree),
126		DebugRequest::Values => return Err(DebugOp::Values),
127		DebugRequest::Resize => return Err(DebugOp::Resize),
128		DebugRequest::Quit => return Err(DebugOp::Quit),
129		DebugRequest::Inject(events) => {
130			let injected = events.len();
131			if events
132				.into_iter()
133				.all(|event| crate::pump::send_event(TerminalEvent::Input(event)))
134			{
135				json!({ "ok": true, "injected": injected })
136			} else {
137				json!({ "ok": false, "error": "no live terminal to inject into" })
138			}
139		},
140		DebugRequest::Events(events) => {
141			let injected = events.len();
142			if events.into_iter().all(crate::pump::send_event) {
143				json!({ "ok": true, "injected": injected })
144			} else {
145				json!({ "ok": false, "error": "no live terminal to inject into" })
146			}
147		},
148		DebugRequest::Bytes(bytes) => {
149			// Raw bytes are a debug action the event actor decodes, so they
150			// run through the live decoder state before emitting input.
151			let fed = bytes.len();
152			if crate::pump::inject_bytes(bytes) {
153				json!({ "ok": true, "fed": fed })
154			} else {
155				json!({ "ok": false, "error": "no live terminal to inject into" })
156			}
157		},
158	})
159}
160
161/// Answers the debug queries the terminal itself owns; `None` passes the
162/// query on to the host (retained-tree state).
163///
164/// Called by [`crate::Terminal::next`] when a [`DebugQuery`] is dequeued,
165/// so answers observe every previously injected event. `Resize` emulates a
166/// SIGWINCH as its side effect; `Quit`'s `C-c` emission stays with the
167/// caller.
168pub fn terminal_response(op: DebugOp) -> Option<serde_json::Value> {
169	use serde_json::json;
170	let snapshot = |build: fn(ScreenSnapshot) -> serde_json::Value| {
171		screen_snapshot()
172			.map_or_else(|| json!({ "ok": false, "error": "no frame painted yet" }), build)
173	};
174	match op {
175		DebugOp::Info => Some(snapshot(|snapshot| {
176			json!({
177				"ok": true,
178				"cols": snapshot.cols,
179				"rows": snapshot.rows,
180				"height": snapshot.doc_height,
181				"window_top": snapshot.window_top,
182				"alt_screen": crate::terminal::alt_screen_active(),
183				"overlay": snapshot.overlay,
184			})
185		})),
186		DebugOp::Text => Some(snapshot(|snapshot| {
187			json!({
188				"ok": true,
189				"lines": snapshot.lines,
190				"cursor": snapshot.cursor.map(|(row, col)| vec![row, col]),
191				"window_top": snapshot.window_top,
192				"alt_screen": crate::terminal::alt_screen_active(),
193			})
194		})),
195		DebugOp::Resize => {
196			crate::terminal::simulate_resize_signal();
197			Some(json!({ "ok": true, "signalled": true }))
198		},
199		DebugOp::Quit => Some(json!({ "ok": true, "injected": "C-c" })),
200		DebugOp::Frame | DebugOp::Tree | DebugOp::Values => None,
201	}
202}
203
204/// One parsed debug request.
205pub enum DebugRequest {
206	Info,
207	Text,
208	Frame,
209	Tree,
210	Values,
211	/// Events to inject into the mailbox, already decoded.
212	Inject(Vec<InputEvent>),
213	/// Serialized terminal events to inject verbatim.
214	Events(Vec<TerminalEvent>),
215	/// Raw bytes for the live input decoder.
216	Bytes(Vec<u8>),
217	Resize,
218	Quit,
219}
220
221/// Parses one request line into a [`DebugRequest`].
222pub fn parse_request(line: &[u8]) -> Result<DebugRequest, String> {
223	let value: serde_json::Value =
224		serde_json::from_slice(line).map_err(|error| format!("malformed request: {error}"))?;
225	let op = value
226		.get("op")
227		.and_then(serde_json::Value::as_str)
228		.ok_or_else(|| "missing \"op\"".to_owned())?;
229	match op {
230		"info" => Ok(DebugRequest::Info),
231		"text" => Ok(DebugRequest::Text),
232		"frame" => Ok(DebugRequest::Frame),
233		"tree" => Ok(DebugRequest::Tree),
234		"values" => Ok(DebugRequest::Values),
235		"keys" => {
236			let spec = value
237				.get("keys")
238				.and_then(serde_json::Value::as_str)
239				.ok_or_else(|| "keys op needs a \"keys\" string".to_owned())?;
240			Ok(DebugRequest::Inject(parse_keys(spec)?.into_iter().map(InputEvent::Key).collect()))
241		},
242		"bytes" => {
243			let data = value
244				.get("data")
245				.and_then(serde_json::Value::as_str)
246				.ok_or_else(|| "bytes op needs a \"data\" string".to_owned())?;
247			Ok(DebugRequest::Bytes(data.as_bytes().to_vec()))
248		},
249		"paste" => {
250			let text = value
251				.get("text")
252				.and_then(serde_json::Value::as_str)
253				.ok_or_else(|| "paste op needs a \"text\" string".to_owned())?;
254			Ok(DebugRequest::Inject(vec![InputEvent::Paste(text.into())]))
255		},
256		"mouse" => Ok(DebugRequest::Inject(vec![InputEvent::Mouse(parse_mouse(&value)?)])),
257		"event" | "events" => {
258			let payload = value
259				.get("event")
260				.or_else(|| value.get("events"))
261				.ok_or_else(|| "event op needs an \"event\" (or \"events\") field".to_owned())?;
262			let events = if payload.is_array() {
263				serde_json::from_value::<Vec<TerminalEvent>>(payload.clone())
264			} else {
265				serde_json::from_value::<TerminalEvent>(payload.clone()).map(|event| vec![event])
266			}
267			.map_err(|error| format!("malformed terminal event: {error}"))?;
268			Ok(DebugRequest::Events(events))
269		},
270		"resize" => Ok(DebugRequest::Resize),
271		"quit" => Ok(DebugRequest::Quit),
272		other => Err(format!("unknown op {other:?}")),
273	}
274}
275
276/// Parses a whitespace-separated key spec.
277///
278/// Named tokens (`tab`, `enter`, `esc`, `up`, `pgdn`, `f5`, ...) map to
279/// their [`Key`] variants; `C-x`, `M-x`/`A-x`, and `C-M-x` are chords; a
280/// single-quoted or double-quoted token types its characters literally, as
281/// does any single-character token.
282pub fn parse_keys(spec: &str) -> Result<Vec<Key>, String> {
283	let mut keys = Vec::new();
284	let mut rest = spec.trim_start();
285	while !rest.is_empty() {
286		if let Some(quote) = rest.chars().next().filter(|ch| matches!(ch, '\'' | '"')) {
287			let body = &rest[quote.len_utf8()..];
288			let end = body
289				.find(quote)
290				.ok_or_else(|| format!("unterminated quote in key spec: {rest:?}"))?;
291			keys.extend(body[..end].chars().map(literal_key));
292			rest = body[end + quote.len_utf8()..].trim_start();
293			continue;
294		}
295		let token = rest
296			.split_whitespace()
297			.next()
298			.expect("non-empty trimmed spec");
299		keys.push(parse_token(token)?);
300		rest = rest[token.len()..].trim_start();
301	}
302	Ok(keys)
303}
304
305/// The key the terminal decoder would produce for typing `ch`.
306const fn literal_key(ch: char) -> Key {
307	match ch {
308		' ' => Key::Space,
309		_ => Key::Char(ch),
310	}
311}
312
313fn parse_token(token: &str) -> Result<Key, String> {
314	// Chord prefixes; a bare single character falls through to literal.
315	if token.chars().count() > 1 {
316		let lower = token.to_ascii_lowercase();
317		if let Some(ch) = strip_chord(&lower, &["c-m-", "m-c-", "ctrl-alt-"]) {
318			return Ok(Key::CtrlAlt(ch));
319		}
320		if let Some(ch) = strip_chord(&lower, &["c-", "ctrl-", "ctrl+"]) {
321			return Ok(Key::Ctrl(ch));
322		}
323		if let Some(ch) = strip_chord(&lower, &["m-", "a-", "alt-", "alt+"]) {
324			return Ok(Key::Alt(ch));
325		}
326	}
327	let named = match token.to_ascii_lowercase().as_str() {
328		"up" => Key::Up,
329		"down" => Key::Down,
330		"left" => Key::Left,
331		"right" => Key::Right,
332		"tab" => Key::Tab,
333		"backtab" | "shift-tab" => Key::BackTab,
334		"enter" | "return" | "cr" => Key::Enter,
335		"space" => Key::Space,
336		"esc" | "escape" => Key::Esc,
337		"backspace" | "bs" => Key::Backspace,
338		"delete" | "del" => Key::Delete,
339		"insert" => Key::Insert,
340		"home" => Key::Home,
341		"end" => Key::End,
342		"pgup" | "pageup" => Key::PageUp,
343		"pgdn" | "pagedown" => Key::PageDown,
344		"shift-enter" => Key::ShiftEnter,
345		"word-left" => Key::WordLeft,
346		"word-right" => Key::WordRight,
347		"word-delete" => Key::WordDelete,
348		other => {
349			if let Some(number) = other.strip_prefix('f')
350				&& let Ok(number) = number.parse::<u8>()
351				&& (1..=12).contains(&number)
352			{
353				return Ok(Key::Function(number));
354			}
355			let mut chars = token.chars();
356			return match (chars.next(), chars.next()) {
357				(Some(ch), None) => Ok(literal_key(ch)),
358				_ => Err(format!("unknown key token {token:?}")),
359			};
360		},
361	};
362	Ok(named)
363}
364
365/// Strips one chord prefix and requires a single trailing character.
366fn strip_chord(token: &str, prefixes: &[&str]) -> Option<char> {
367	prefixes.iter().find_map(|prefix| {
368		let rest = token.strip_prefix(prefix)?;
369		let mut chars = rest.chars();
370		match (chars.next(), chars.next()) {
371			(Some(ch), None) => Some(ch),
372			_ => None,
373		}
374	})
375}
376
377fn parse_mouse(value: &serde_json::Value) -> Result<MouseReport, String> {
378	let coordinate = |name: &str| -> Result<u16, String> {
379		value
380			.get(name)
381			.and_then(serde_json::Value::as_u64)
382			.and_then(|number| u16::try_from(number).ok())
383			.ok_or_else(|| format!("mouse op needs a numeric \"{name}\""))
384	};
385	let col = coordinate("x")?;
386	let row = coordinate("y")?;
387	let action = value
388		.get("action")
389		.and_then(serde_json::Value::as_str)
390		.unwrap_or("click");
391	let (kind, button, pressed) = match action {
392		"click" | "press" => (Mouse::Click, MouseButton::Left, true),
393		"right-click" => (Mouse::RightClick, MouseButton::Right, true),
394		"middle-click" => (Mouse::MiddleClick, MouseButton::Middle, true),
395		"move" => (Mouse::Move, MouseButton::None, false),
396		"drag" => (Mouse::Drag, MouseButton::Left, true),
397		"release" => (Mouse::Release, MouseButton::Left, false),
398		"wheel-up" => (Mouse::WheelUp, MouseButton::WheelUp, true),
399		"wheel-down" => (Mouse::WheelDown, MouseButton::WheelDown, true),
400		"wheel-left" => (Mouse::WheelLeft, MouseButton::WheelLeft, true),
401		"wheel-right" => (Mouse::WheelRight, MouseButton::WheelRight, true),
402		other => return Err(format!("unknown mouse action {other:?}")),
403	};
404	Ok(MouseReport { kind, col, row, button, mods: Default::default(), pressed })
405}
406
407/// Whether the server thread was (attempted to be) started.
408#[cfg(unix)]
409static SERVER_STARTED: AtomicBool = AtomicBool::new(false);
410
411/// Starts the debug server thread once when `OMP_TUI_DEBUG` is set.
412///
413/// The socket binds here so a bad path fails loudly in the caller; the
414/// thread then owns the listener and answers requests on its own async
415/// loop. Idempotent; called on terminal entry.
416#[cfg(unix)]
417pub fn ensure_server() -> std::io::Result<()> {
418	if !enabled() || SERVER_STARTED.swap(true, Ordering::AcqRel) {
419		return Ok(());
420	}
421	server::spawn_thread()
422}
423
424#[cfg(not(unix))]
425pub(crate) fn ensure_server() -> std::io::Result<()> {
426	Ok(())
427}
428
429#[cfg(unix)]
430mod server {
431	use std::{io, path::PathBuf, task::Poll, time::Duration};
432
433	use tokio::net::{UnixListener, UnixStream};
434
435	use super::{
436		DEBUG_ENV, DebugQuery, DebugRequest, RESPONSES, TerminalEvent, direct_response, parse_request,
437	};
438
439	/// How long a retained-state query may wait for a host answer; hosts
440	/// without a retained tree never answer, so expiry is the normal path
441	/// for them.
442	const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
443
444	/// Binds the socket and starts the `omp-tui-debug` thread running the
445	/// async serve loop on a dedicated current-thread runtime.
446	///
447	/// Binding happens on the caller so a set-but-unbindable path is a loud
448	/// error rather than a silently missing socket.
449	pub(super) fn spawn_thread() -> io::Result<()> {
450		let path = PathBuf::from(
451			std::env::var_os(DEBUG_ENV).expect("enabled() checked the variable before spawning"),
452		);
453		match std::fs::remove_file(&path) {
454			Ok(()) => {},
455			Err(error) if error.kind() == io::ErrorKind::NotFound => {},
456			Err(error) => return Err(error),
457		}
458		let listener = std::os::unix::net::UnixListener::bind(&path)?;
459		listener.set_nonblocking(true)?;
460		let (responses_tx, responses_rx) = flume::unbounded();
461		*RESPONSES.lock() = Some(responses_tx);
462		std::thread::Builder::new()
463			.name("omp-tui-debug".into())
464			.spawn(move || {
465				let runtime = tokio::runtime::Builder::new_current_thread()
466					.enable_io()
467					.enable_time()
468					.build()
469					.expect("debug server runtime builds");
470				runtime.block_on(async move {
471					let Ok(listener) = UnixListener::from_std(listener) else {
472						return;
473					};
474					let mut server = DebugServer::new(listener);
475					serve_loop(&mut server, responses_rx).await;
476				});
477			})?;
478		Ok(())
479	}
480
481	/// One in-flight retained-state query.
482	struct PendingQuery {
483		id:      u64,
484		client:  u64,
485		expires: tokio::time::Instant,
486	}
487
488	/// Answers requests forever.
489	///
490	/// Input, screen, resize, and quit ops resolve immediately
491	/// ([`direct_response`]); retained-state ops ride the terminal mailbox
492	/// as [`TerminalEvent::Debug`] queries and resolve when the host calls
493	/// [`super::respond_debug_query`] — or expire for hosts that never answer.
494	async fn serve_loop(
495		server: &mut DebugServer,
496		responses: flume::Receiver<(u64, serde_json::Value)>,
497	) {
498		let mut pending: Vec<PendingQuery> = Vec::new();
499		let mut next_id = 1_u64;
500		loop {
501			let expiry = pending.iter().map(|query| query.expires).min();
502			tokio::select! {
503				received = server.recv() => {
504					let (client, request) = received;
505					let request = match request {
506						Err(error) => {
507							server
508								.respond(client, &serde_json::json!({ "ok": false, "error": error }));
509							continue;
510						},
511						Ok(request) => request,
512					};
513					match direct_response(request) {
514						Ok(response) => server.respond(client, &response),
515						Err(op) => {
516							let id = next_id;
517							next_id += 1;
518							if crate::pump::send_event(TerminalEvent::Debug(DebugQuery { id, op })) {
519								pending.push(PendingQuery {
520									id,
521									client,
522									expires: tokio::time::Instant::now() + QUERY_TIMEOUT,
523								});
524							} else {
525								server.respond(client, &serde_json::json!({
526									"ok": false,
527									"error": "no live terminal to query",
528								}));
529							}
530						},
531					}
532				},
533				response = responses.recv_async() => {
534					let Ok((id, response)) = response else {
535						return;
536					};
537					if let Some(index) = pending.iter().position(|query| query.id == id) {
538						let query = pending.swap_remove(index);
539						server.respond(query.client, &response);
540					}
541				},
542				() = expire(expiry) => {
543					let now = tokio::time::Instant::now();
544					let mut index = 0;
545					while index < pending.len() {
546						if pending[index].expires <= now {
547							let query = pending.swap_remove(index);
548							server.respond(query.client, &serde_json::json!({
549								"ok": false,
550								"error": "no retained host answered; use `text` (omp_tui::App answers frame/tree/values)",
551							}));
552						} else {
553							index += 1;
554						}
555					}
556				},
557			}
558		}
559	}
560
561	/// Sleeps until the earliest pending expiry; pending forever without one.
562	async fn expire(at: Option<tokio::time::Instant>) {
563		match at {
564			Some(at) => tokio::time::sleep_until(at).await,
565			None => std::future::pending().await,
566		}
567	}
568
569	/// Line-framed JSON server owned by the debug thread.
570	struct DebugServer {
571		listener:  UnixListener,
572		conns:     Vec<Conn>,
573		next_conn: u64,
574	}
575
576	struct Conn {
577		/// Stable client id; survives [`DebugServer::recv`]'s compaction of
578		/// dead connections, so pending queries can hold it across calls.
579		id:     u64,
580		stream: UnixStream,
581		buf:    Vec<u8>,
582		out:    Vec<u8>,
583		dead:   bool,
584	}
585
586	impl Conn {
587		/// Pops one complete request line, excluding its newline.
588		fn take_line(&mut self) -> Option<Vec<u8>> {
589			let end = self.buf.iter().position(|byte| *byte == b'\n')?;
590			let line = self.buf[..end].to_vec();
591			self.buf.drain(..=end);
592			Some(line)
593		}
594
595		/// Drains readable bytes; EOF or a hard error retires the connection.
596		fn fill(&mut self) {
597			let mut bytes = [0_u8; 4096];
598			loop {
599				match self.stream.try_read(&mut bytes) {
600					Ok(0) => {
601						self.dead = true;
602						return;
603					},
604					Ok(read) => self.buf.extend_from_slice(&bytes[..read]),
605					Err(error) if error.kind() == io::ErrorKind::WouldBlock => return,
606					Err(_) => {
607						self.dead = true;
608						return;
609					},
610				}
611			}
612		}
613
614		/// Writes as much pending response output as the socket accepts;
615		/// the rest flushes when [`DebugServer::recv`] sees it writable.
616		fn flush(&mut self) {
617			while !self.out.is_empty() {
618				match self.stream.try_write(&self.out) {
619					Ok(0) => {
620						self.dead = true;
621						return;
622					},
623					Ok(written) => {
624						self.out.drain(..written);
625					},
626					Err(error) if error.kind() == io::ErrorKind::WouldBlock => return,
627					Err(_) => {
628						self.dead = true;
629						return;
630					},
631				}
632			}
633		}
634	}
635
636	impl DebugServer {
637		const fn new(listener: UnixListener) -> Self {
638			Self { listener, conns: Vec::new(), next_conn: 1 }
639		}
640
641		/// Waits for the next complete request from any client, flushing
642		/// buffered responses as their sockets drain.
643		///
644		/// Cancel-safe: partial lines stay buffered per connection. The
645		/// returned client id addresses the sender for
646		/// [`DebugServer::respond`] and stays stable for the connection's
647		/// lifetime, so pending queries may hold it across `recv` calls.
648		async fn recv(&mut self) -> (u64, Result<DebugRequest, String>) {
649			loop {
650				self.conns.retain(|conn| !conn.dead);
651				for conn in &mut self.conns {
652					if let Some(line) = conn.take_line() {
653						return (conn.id, parse_request(&line));
654					}
655				}
656
657				let Self { listener, conns, next_conn } = self;
658				tokio::select! {
659					accepted = listener.accept() => {
660						if let Ok((stream, _)) = accepted {
661							let id = *next_conn;
662							*next_conn += 1;
663							conns.push(Conn {
664								id,
665								stream,
666								buf: Vec::new(),
667								out: Vec::new(),
668								dead: false,
669							});
670						}
671					},
672					index = ready(conns) => {
673						conns[index].fill();
674						conns[index].flush();
675					},
676				}
677			}
678		}
679
680		/// Queues one JSON response line for the client with id `client` and
681		/// flushes it immediately when the socket has room; leftovers flush
682		/// when [`DebugServer::recv`] sees the socket writable. Responses to
683		/// disconnected clients are dropped.
684		fn respond(&mut self, client: u64, response: &serde_json::Value) {
685			let Some(conn) = self.conns.iter_mut().find(|conn| conn.id == client) else {
686				return;
687			};
688			serde_json::to_writer(&mut conn.out, response).expect("JSON responses serialize");
689			conn.out.push(b'\n');
690			conn.flush();
691		}
692	}
693
694	/// Resolves with the index of the first connection that is readable — or
695	/// writable while holding buffered response output; pending while none
696	/// are (including the empty set, leaving `accept` to wake).
697	async fn ready(conns: &[Conn]) -> usize {
698		std::future::poll_fn(|cx| {
699			for (index, conn) in conns.iter().enumerate() {
700				if conn.stream.poll_read_ready(cx).is_ready()
701					|| (!conn.out.is_empty() && conn.stream.poll_write_ready(cx).is_ready())
702				{
703					return Poll::Ready(index);
704				}
705			}
706			Poll::Pending
707		})
708		.await
709	}
710
711	#[cfg(test)]
712	mod tests {
713		use std::time::Duration;
714
715		use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
716
717		use super::{DebugServer, serve_loop};
718
719		/// A disconnect must not reroute a pending query's reply: client A
720		/// parks a retained query and dies, client B occupies A's compacted
721		/// slot, and the late host reply for A has to be dropped rather
722		/// than delivered to B.
723		#[tokio::test]
724		async fn pending_query_replies_follow_stable_client_ids() {
725			let ingress = crate::pump::publish_ingress_for_test();
726			let (responses_tx, responses_rx) = flume::unbounded();
727
728			let path =
729				std::env::temp_dir().join(format!("omp-tui-debug-idtest-{}.sock", std::process::id()));
730			let _ = std::fs::remove_file(&path);
731			let listener = std::os::unix::net::UnixListener::bind(&path).expect("test socket binds");
732			listener
733				.set_nonblocking(true)
734				.expect("nonblocking listener");
735			let listener = tokio::net::UnixListener::from_std(listener).expect("listener registers");
736			let mut server = DebugServer::new(listener);
737			let serve = tokio::spawn(async move { serve_loop(&mut server, responses_rx).await });
738
739			// Client A parks a retained query (id 1), then disconnects.
740			let mut first = tokio::net::UnixStream::connect(&path)
741				.await
742				.expect("first client connects");
743			first
744				.write_all(b"{\"op\":\"tree\"}\n")
745				.await
746				.expect("query sends");
747			tokio::time::timeout(Duration::from_secs(1), ingress.recv_async())
748				.await
749				.expect("query reaches the ingress")
750				.expect("ingress lives");
751			drop(first);
752
753			// Client B lands on the compacted slot an index token would
754			// still name and gets its own answer.
755			let second = tokio::net::UnixStream::connect(&path)
756				.await
757				.expect("second client connects");
758			let mut second = BufReader::new(second);
759			second
760				.get_mut()
761				.write_all(b"{\"op\":\"keys\",\"keys\":\"x\"}\n")
762				.await
763				.expect("injection sends");
764			let mut line = String::new();
765			tokio::time::timeout(Duration::from_secs(1), second.read_line(&mut line))
766				.await
767				.expect("injection is acknowledged")
768				.expect("ack line reads");
769			assert!(line.contains("\"injected\""), "unexpected ack: {line:?}");
770
771			// The host reply for the dead client is dropped, not rerouted.
772			responses_tx
773				.send((1, serde_json::json!({ "leak": "wrong-client", "ok": true })))
774				.expect("reply channel lives");
775			line.clear();
776			let stray =
777				tokio::time::timeout(Duration::from_millis(200), second.read_line(&mut line)).await;
778			assert!(
779				stray.is_err() || line.is_empty(),
780				"reply for a disconnected client reached the survivor: {line:?}"
781			);
782
783			serve.abort();
784			let _ = std::fs::remove_file(&path);
785		}
786	}
787}
788
789#[cfg(test)]
790mod tests {
791	use super::*;
792
793	#[test]
794	fn key_spec_tokens_chords_and_literals() {
795		let keys = parse_keys("tab C-c M-y 'hi there' x pgdn f5").expect("valid spec");
796		assert_eq!(keys, vec![
797			Key::Tab,
798			Key::Ctrl('c'),
799			Key::Alt('y'),
800			Key::Char('h'),
801			Key::Char('i'),
802			Key::Space,
803			Key::Char('t'),
804			Key::Char('h'),
805			Key::Char('e'),
806			Key::Char('r'),
807			Key::Char('e'),
808			Key::Char('x'),
809			Key::PageDown,
810			Key::Function(5),
811		]);
812	}
813
814	#[test]
815	fn key_spec_rejects_unknown_and_unterminated() {
816		assert!(parse_keys("bogus-token").is_err());
817		assert!(parse_keys("'open").is_err());
818	}
819
820	#[test]
821	fn request_lines_parse_by_op() {
822		assert!(matches!(parse_request(br#"{"op":"text"}"#), Ok(DebugRequest::Text)));
823		assert!(matches!(
824			parse_request(br#"{"op":"keys","keys":"enter"}"#),
825			Ok(DebugRequest::Inject(events)) if events == vec![InputEvent::Key(Key::Enter)]
826		));
827		assert!(parse_request(br#"{"op":"warp"}"#).is_err());
828		let mouse = parse_request(br#"{"op":"mouse","x":3,"y":7,"action":"wheel-down"}"#);
829		assert!(matches!(
830			mouse,
831			Ok(DebugRequest::Inject(events))
832				if matches!(&events[..], [InputEvent::Mouse(report)]
833					if report.kind == Mouse::WheelDown && report.col == 3 && report.row == 7)
834		));
835	}
836}