#[cfg(unix)]
use crate::core::trigger::Observation;
use crate::term::theme_notify::{OscColorKind, parse_color_scheme_report, parse_osc_color_reply};
use crate::theme::Appearance;
use crate::ui::key::Key;
#[derive(Clone, PartialEq, Debug)]
pub enum TapEvent {
Key(Key),
Mouse(MouseEvent),
ThemeNotification(Appearance),
OscColor(OscColorKind, xterm_color::Color),
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct MouseEvent {
pub kind: MouseKind,
pub shift: bool,
pub notches: u16,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum MouseKind {
WheelUp,
WheelDown,
WheelLeft,
WheelRight,
Other,
}
const MAX_ESCAPE_LEN: usize = 128;
pub const ESC_HOLD: std::time::Duration = std::time::Duration::from_millis(50);
pub struct TapScanner {
buf: Vec<u8>,
silent: std::time::Duration,
x10_pending: u8,
}
impl TapScanner {
pub fn new() -> TapScanner {
TapScanner {
buf: Vec::new(),
silent: std::time::Duration::ZERO,
x10_pending: 0,
}
}
pub fn feed(&mut self, chunk: &[u8]) -> Vec<TapEvent> {
self.silent = std::time::Duration::ZERO;
let mut events = Vec::new();
for &byte in chunk {
if self.x10_pending > 0 {
self.x10_pending -= 1;
continue;
}
if self.buf.is_empty() {
if byte == 0x1b {
self.buf.push(byte);
} else if let Some(key) = decode_key(byte) {
events.push(TapEvent::Key(key));
}
continue;
}
self.buf.push(byte);
if self.buf.len() == 2 {
if !matches!(self.buf[1], b'[' | b']' | b'O') {
self.buf.clear();
if let Some(key) = decode_key(byte) {
events.push(TapEvent::Key(key));
}
}
continue;
}
if self.buf.len() > MAX_ESCAPE_LEN {
self.buf.clear();
continue;
}
if self.buf[1] == b'[' {
if (0x40..=0x7e).contains(&byte) {
if self.buf == b"\x1b[M" {
self.x10_pending = 3;
} else if let Some(appearance) = parse_color_scheme_report(&self.buf) {
events.push(TapEvent::ThemeNotification(appearance));
} else if let Some(mouse) = parse_sgr_mouse(&self.buf) {
events.push(TapEvent::Mouse(mouse));
} else if let Some(key) = decode_csi(&self.buf) {
events.push(TapEvent::Key(key));
}
self.buf.clear();
}
} else if self.buf[1] == b'O' {
if let Some(key) = decode_ss3(byte) {
events.push(TapEvent::Key(key));
}
self.buf.clear();
} else {
debug_assert_eq!(self.buf[1], b']');
if self.buf.ends_with(b"\x07") || self.buf.ends_with(b"\x1b\\") {
if let Some((kind, color)) = parse_osc_color_reply(&self.buf) {
events.push(TapEvent::OscColor(kind, color));
}
self.buf.clear();
}
}
}
events
}
pub fn idle(&mut self, silence: std::time::Duration) -> Vec<TapEvent> {
if self.buf != [0x1b] {
return Vec::new();
}
self.silent += silence;
if self.silent < ESC_HOLD {
return Vec::new();
}
self.buf.clear();
self.silent = std::time::Duration::ZERO;
vec![TapEvent::Key(Key::Esc)]
}
}
impl Default for TapScanner {
fn default() -> Self {
TapScanner::new()
}
}
pub fn decode_key(byte: u8) -> Option<Key> {
match byte {
0x03 => Some(Key::CtrlC),
b'\r' | b'\n' => Some(Key::Enter),
0x20..=0x7e => Some(Key::Char(byte as char)),
_ => None,
}
}
pub fn parse_sgr_mouse(seq: &[u8]) -> Option<MouseEvent> {
let final_byte = *seq.last()?;
if !matches!(final_byte, b'M' | b'm') {
return None;
}
let body = seq.strip_prefix(b"\x1b[<")?;
let body = &body[..body.len() - 1];
let mut parts = body.split(|&b| b == b';');
let cb: u16 = std::str::from_utf8(parts.next()?).ok()?.parse().ok()?;
for _ in 0..2 {
let _: u16 = std::str::from_utf8(parts.next()?).ok()?.parse().ok()?;
}
if parts.next().is_some() {
return None;
}
let shift = cb & 4 != 0;
let kind = match (cb & !28, final_byte) {
_ if cb & 32 != 0 => MouseKind::Other,
(64, b'M') => MouseKind::WheelUp,
(65, b'M') => MouseKind::WheelDown,
(66, b'M') => MouseKind::WheelLeft,
(67, b'M') => MouseKind::WheelRight,
_ => MouseKind::Other,
};
Some(MouseEvent {
kind,
shift,
notches: 1,
})
}
pub fn decode_csi(seq: &[u8]) -> Option<Key> {
match seq {
b"\x1b[A" => Some(Key::Up),
b"\x1b[B" => Some(Key::Down),
b"\x1b[C" => Some(Key::Right),
b"\x1b[D" => Some(Key::Left),
b"\x1b[H" | b"\x1b[1~" | b"\x1b[7~" => Some(Key::Home),
b"\x1b[F" | b"\x1b[4~" | b"\x1b[8~" => Some(Key::End),
b"\x1b[5~" => Some(Key::PageUp),
b"\x1b[6~" => Some(Key::PageDown),
_ => None,
}
}
pub fn decode_ss3(final_byte: u8) -> Option<Key> {
match final_byte {
b'A' => Some(Key::Up),
b'B' => Some(Key::Down),
b'C' => Some(Key::Right),
b'D' => Some(Key::Left),
b'H' => Some(Key::Home),
b'F' => Some(Key::End),
_ => None,
}
}
#[cfg(unix)]
const READ_SLICE: std::time::Duration = std::time::Duration::from_millis(50);
#[cfg(unix)]
const PARK_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
#[cfg(unix)]
#[derive(Default)]
struct TapControl {
pause: std::sync::atomic::AtomicBool,
parked: std::sync::atomic::AtomicBool,
shutdown: std::sync::atomic::AtomicBool,
}
#[cfg(unix)]
#[derive(Clone, PartialEq, Debug)]
pub enum TapChunk {
Tty(Vec<u8>),
Trigger,
}
#[cfg(unix)]
pub struct TtyTap {
rx: std::sync::mpsc::Receiver<TapChunk>,
tx: std::sync::mpsc::Sender<TapChunk>,
control: std::sync::Arc<TapControl>,
reader: Option<std::thread::JoinHandle<()>>,
}
#[cfg(unix)]
impl TtyTap {
pub fn spawn() -> std::io::Result<TtyTap> {
let tty = std::fs::File::open("/dev/tty")?;
let (tx, rx) = std::sync::mpsc::channel();
let control = std::sync::Arc::new(TapControl::default());
let reader_control = std::sync::Arc::clone(&control);
let reader_tx = tx.clone();
let reader = std::thread::Builder::new()
.name("rat-tty-tap".to_string())
.spawn(move || read_loop(&tty, &reader_tx, &reader_control))?;
Ok(TtyTap {
rx,
tx,
control,
reader: Some(reader),
})
}
pub fn sender(&self) -> std::sync::mpsc::Sender<TapChunk> {
self.tx.clone()
}
pub fn recv_timeout(&self, timeout: std::time::Duration) -> Option<TapChunk> {
use std::sync::mpsc::RecvTimeoutError;
match self.rx.recv_timeout(timeout) {
Ok(chunk) => Some(chunk),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => {
std::thread::sleep(timeout);
None
}
}
}
pub fn pause(&self) -> bool {
use std::sync::atomic::Ordering;
self.control.pause.store(true, Ordering::SeqCst);
let deadline = std::time::Instant::now() + PARK_ACK_TIMEOUT;
loop {
if self.control.parked.load(Ordering::SeqCst) {
return true;
}
if self
.reader
.as_ref()
.is_none_or(|reader| reader.is_finished())
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
pub fn resume(&self) {
use std::sync::atomic::Ordering;
self.control.parked.store(false, Ordering::SeqCst);
self.control.pause.store(false, Ordering::SeqCst);
}
}
#[cfg(unix)]
impl Drop for TtyTap {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.control.shutdown.store(true, Ordering::SeqCst);
self.control.pause.store(false, Ordering::SeqCst);
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
#[cfg(unix)]
fn read_loop(tty: &std::fs::File, tx: &std::sync::mpsc::Sender<TapChunk>, control: &TapControl) {
use std::os::unix::io::AsRawFd;
use std::sync::atomic::Ordering;
let fd = tty.as_raw_fd();
let mut buf = [0u8; 256];
loop {
if control.shutdown.load(Ordering::SeqCst) {
return;
}
if control.pause.load(Ordering::SeqCst) {
control.parked.store(true, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(2));
continue;
}
let mut read_set: libc::fd_set = unsafe { std::mem::zeroed() };
unsafe {
libc::FD_ZERO(&mut read_set);
libc::FD_SET(fd, &mut read_set);
}
let mut timeout = libc::timeval {
tv_sec: 0,
tv_usec: READ_SLICE.subsec_micros() as libc::suseconds_t,
};
let ready = unsafe {
libc::select(
fd + 1,
&mut read_set,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut timeout,
)
};
if ready < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return;
}
if ready == 0 {
continue;
}
if control.pause.load(Ordering::SeqCst) {
continue;
}
let read = unsafe { libc::read(fd, buf.as_mut_ptr().cast::<libc::c_void>(), buf.len()) };
if read <= 0 {
return; }
if tx
.send(TapChunk::Tty(buf[..read as usize].to_vec()))
.is_err()
{
return; }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_posted_trigger_wakes_the_receiver_early() {
let Ok(tap) = TtyTap::spawn() else { return };
let sender = tap.sender();
std::thread::spawn(move || {
let _ = sender.send(TapChunk::Trigger);
});
let start = std::time::Instant::now();
let got = tap.recv_timeout(std::time::Duration::from_secs(5));
assert_eq!(got, Some(TapChunk::Trigger));
assert!(
start.elapsed() < std::time::Duration::from_secs(4),
"the trigger did not wake the receiver early"
);
}
#[test]
fn a_split_report_reassembles_across_feeds() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[?997"), vec![]);
assert_eq!(
scanner.feed(b";2n"),
vec![TapEvent::ThemeNotification(Appearance::Light)]
);
}
#[test]
fn a_report_sandwiched_between_keys_yields_all_three_in_order() {
let mut scanner = TapScanner::new();
let events = scanner.feed(b"a\x1b[?997;2nb");
assert_eq!(
events,
vec![
TapEvent::Key(Key::Char('a')),
TapEvent::ThemeNotification(Appearance::Light),
TapEvent::Key(Key::Char('b')),
]
);
}
#[test]
fn an_unrecognized_private_csi_is_dropped_without_wedging() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[?123;4x"), vec![]);
assert_eq!(scanner.feed(b"z"), vec![TapEvent::Key(Key::Char('z'))]);
}
#[test]
fn sgr_wheel_reports_decode_and_are_never_keys() {
let mut scanner = TapScanner::new();
let wheel = |kind, shift| {
TapEvent::Mouse(MouseEvent {
kind,
shift,
notches: 1,
})
};
assert_eq!(
scanner.feed(b"\x1b[<64;10;5M"),
vec![wheel(MouseKind::WheelUp, false)]
);
assert_eq!(
scanner.feed(b"\x1b[<65;10;5M"),
vec![wheel(MouseKind::WheelDown, false)]
);
assert_eq!(
scanner.feed(b"\x1b[<66;1;1M"),
vec![wheel(MouseKind::WheelLeft, false)]
);
assert_eq!(
scanner.feed(b"\x1b[<67;1;1M"),
vec![wheel(MouseKind::WheelRight, false)]
);
assert_eq!(
scanner.feed(b"\x1b[<69;10;5M"),
vec![wheel(MouseKind::WheelDown, true)]
);
assert_eq!(scanner.feed(b"\x1b[<6"), vec![]);
assert_eq!(
scanner.feed(b"5;10;5M"),
vec![wheel(MouseKind::WheelDown, false)]
);
assert_eq!(
scanner.feed(b"a\x1b[<65;1;1Mb"),
vec![
TapEvent::Key(Key::Char('a')),
wheel(MouseKind::WheelDown, false),
TapEvent::Key(Key::Char('b')),
]
);
}
#[test]
fn presses_releases_and_motion_map_to_other() {
let mut scanner = TapScanner::new();
for report in [
&b"\x1b[<0;10;5M"[..],
&b"\x1b[<0;10;5m"[..],
&b"\x1b[<35;10;5M"[..],
] {
let events = scanner.feed(report);
assert_eq!(events.len(), 1, "{report:?}");
assert!(
matches!(
events[0],
TapEvent::Mouse(MouseEvent {
kind: MouseKind::Other,
..
})
),
"{report:?} decoded {events:?}"
);
}
}
#[test]
fn a_malformed_sgr_mouse_run_drops_without_becoming_a_key() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[<;;M"), vec![]);
assert_eq!(scanner.feed(b"\x1b[<65;2M"), vec![]);
assert_eq!(scanner.feed(b"\x1b[<65;2;3;4M"), vec![]);
assert_eq!(scanner.feed(b"z"), vec![TapEvent::Key(Key::Char('z'))]);
}
#[test]
fn an_x10_mouse_report_never_produces_a_key() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[M q!"), vec![]);
assert_eq!(scanner.feed(b"\x1b[M"), vec![]);
assert_eq!(scanner.feed(b" q"), vec![]);
assert_eq!(scanner.feed(b"!"), vec![]);
assert_eq!(
scanner.feed(b"\x1b[M q!z"),
vec![TapEvent::Key(Key::Char('z'))]
);
}
#[test]
fn an_unfinished_sequence_past_the_cap_is_discarded_wholesale() {
let mut scanner = TapScanner::new();
let mut long_run = b"\x1b[".to_vec();
long_run.resize(long_run.len() + 200, 0u8);
assert_eq!(scanner.feed(&long_run), vec![]);
assert_eq!(scanner.feed(b"z"), vec![TapEvent::Key(Key::Char('z'))]);
}
#[test]
fn arrow_keys_decode_through_the_scanner() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[A"), vec![TapEvent::Key(Key::Up)]);
assert_eq!(scanner.feed(b"\x1b[B"), vec![TapEvent::Key(Key::Down)]);
assert_eq!(scanner.feed(b"\x1b[C"), vec![TapEvent::Key(Key::Right)]);
assert_eq!(scanner.feed(b"\x1b[D"), vec![TapEvent::Key(Key::Left)]);
}
#[test]
fn page_and_home_end_sequences_decode() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[5~"), vec![TapEvent::Key(Key::PageUp)]);
assert_eq!(scanner.feed(b"\x1b[6~"), vec![TapEvent::Key(Key::PageDown)]);
assert_eq!(scanner.feed(b"\x1b[H"), vec![TapEvent::Key(Key::Home)]);
assert_eq!(scanner.feed(b"\x1b[F"), vec![TapEvent::Key(Key::End)]);
assert_eq!(scanner.feed(b"\x1b[1~"), vec![TapEvent::Key(Key::Home)]);
assert_eq!(scanner.feed(b"\x1b[4~"), vec![TapEvent::Key(Key::End)]);
assert_eq!(scanner.feed(b"\x1b[7~"), vec![TapEvent::Key(Key::Home)]);
assert_eq!(scanner.feed(b"\x1b[8~"), vec![TapEvent::Key(Key::End)]);
}
#[test]
fn a_theme_report_is_still_a_report_not_a_key() {
let mut scanner = TapScanner::new();
assert_eq!(
scanner.feed(b"\x1b[?997;2n"),
vec![TapEvent::ThemeNotification(Appearance::Light)]
);
}
#[test]
fn a_split_arrow_reassembles_across_feeds() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b["), vec![]);
assert_eq!(scanner.feed(b"B"), vec![TapEvent::Key(Key::Down)]);
}
#[test]
fn an_application_cursor_arrow_decodes() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1bOA"), vec![TapEvent::Key(Key::Up)]);
assert_eq!(scanner.feed(b"\x1bOS"), vec![]);
}
#[test]
fn an_osc_color_reply_reassembles_across_feeds() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b]11;rgb:1e1e/1e1e/"), vec![]);
assert_eq!(
scanner.feed(b"2e2e\x07"),
vec![TapEvent::OscColor(
OscColorKind::Background,
xterm_color::Color::rgb(0x1e1e, 0x1e1e, 0x2e2e)
)]
);
}
#[test]
fn a_lone_escape_with_no_recognized_introducer_does_not_eat_the_next_byte() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b"), vec![]);
assert_eq!(scanner.feed(b"q"), vec![TapEvent::Key(Key::Char('q'))]);
}
#[test]
fn a_lone_escape_resolves_after_the_hold() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b"), vec![]);
assert_eq!(scanner.idle(std::time::Duration::from_millis(20)), vec![]);
assert_eq!(
scanner.idle(std::time::Duration::from_millis(40)),
vec![TapEvent::Key(Key::Esc)]
);
assert_eq!(scanner.idle(std::time::Duration::from_millis(50)), vec![]);
}
#[test]
fn bytes_cancel_a_pending_escape() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b"), vec![]);
assert_eq!(scanner.idle(std::time::Duration::from_millis(30)), vec![]);
assert_eq!(scanner.feed(b"["), vec![]);
assert_eq!(scanner.idle(std::time::Duration::from_millis(50)), vec![]);
assert_eq!(scanner.feed(b"A"), vec![TapEvent::Key(Key::Up)]);
}
#[test]
fn an_escape_followed_by_a_plain_byte_keeps_todays_behavior() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1bq"), vec![TapEvent::Key(Key::Char('q'))]);
}
#[test]
fn idle_never_flushes_a_partial_sequence() {
let mut scanner = TapScanner::new();
assert_eq!(scanner.feed(b"\x1b[?997"), vec![]);
assert_eq!(scanner.idle(std::time::Duration::from_millis(200)), vec![]);
assert_eq!(
scanner.feed(b";2n"),
vec![TapEvent::ThemeNotification(Appearance::Light)]
);
}
#[test]
fn decode_key_maps_the_five_recognized_bytes() {
assert_eq!(decode_key(0x03), Some(Key::CtrlC));
assert_eq!(decode_key(b'\r'), Some(Key::Enter));
assert_eq!(decode_key(b'\n'), Some(Key::Enter));
assert_eq!(decode_key(b'q'), Some(Key::Char('q')));
assert_eq!(decode_key(b'v'), Some(Key::Char('v')));
}
#[test]
fn decode_key_has_no_verdict_for_escape_or_delete() {
assert_eq!(decode_key(0x1b), None);
assert_eq!(decode_key(0x7f), None);
}
}
#[cfg(unix)]
#[derive(Debug)]
pub struct TriggerReader {
fired: std::sync::Arc<std::sync::atomic::AtomicBool>,
ended: std::sync::Arc<std::sync::atomic::AtomicBool>,
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
arrivals: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<Observation>>>,
overflowed: std::sync::Arc<std::sync::atomic::AtomicBool>,
#[cfg_attr(not(test), allow(dead_code))]
max_reads_per_select: std::sync::Arc<std::sync::atomic::AtomicUsize>,
control: (libc::c_int, libc::c_int),
#[cfg_attr(not(test), allow(dead_code))]
empty_since: std::sync::Arc<std::sync::Mutex<Option<std::time::Instant>>>,
#[cfg_attr(not(test), allow(dead_code))]
fences: std::sync::Arc<std::sync::atomic::AtomicUsize>,
#[cfg_attr(not(test), allow(dead_code))]
parked: std::sync::Arc<std::sync::atomic::AtomicBool>,
reader: Option<std::thread::JoinHandle<()>>,
}
#[cfg(unix)]
pub const ARRIVAL_CAP: usize = 256;
#[cfg(unix)]
impl TriggerReader {
pub fn open(
spec: &crate::core::trigger::TriggerSpec,
wake: Option<std::sync::mpsc::Sender<TapChunk>>,
) -> anyhow::Result<TriggerReader> {
use std::os::unix::fs::{FileTypeExt, OpenOptionsExt};
use std::os::unix::io::AsRawFd;
use anyhow::{anyhow, bail};
use crate::core::trigger::TriggerSpec;
let drainable = matches!(spec, crate::core::trigger::TriggerSpec::Fifo(_));
let (fd, keep_alive) = match spec {
TriggerSpec::Fifo(path) => {
let read_end = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.map_err(|err| match err.kind() {
std::io::ErrorKind::NotFound => anyhow!(
"trigger fifo {} does not exist; create it with: mkfifo {}",
path.display(),
path.display()
),
_ => anyhow!("opening trigger fifo {}: {err}", path.display()),
})?;
if !read_end.metadata()?.file_type().is_fifo() {
bail!(
"fifo:{} is not a named pipe; use file:{} for plain paths",
path.display(),
path.display()
);
}
let write_end = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)?;
(read_end.as_raw_fd(), vec![read_end, write_end])
}
TriggerSpec::Fd(fd) => {
let mut stat: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(*fd, &mut stat) } != 0 {
bail!("fd:{fd} is not an open descriptor");
}
if stat.st_mode & libc::S_IFMT == libc::S_IFREG {
bail!(
"fd:{fd} is a regular file, which select(2) always reports \
ready; use file:PATH to watch a file"
);
}
(*fd, Vec::new())
}
TriggerSpec::File(_) => bail!("file: triggers are polled, not read"),
};
let fired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let arrivals = std::sync::Arc::new(std::sync::Mutex::new(
std::collections::VecDeque::with_capacity(ARRIVAL_CAP),
));
let (control_rx, control_tx) = {
let mut ends: [libc::c_int; 2] = [-1, -1];
if unsafe { libc::pipe(ends.as_mut_ptr()) } != 0 {
bail!(
"control pipe for trigger reader: {}",
std::io::Error::last_os_error()
);
}
for end in ends {
let flags = unsafe { libc::fcntl(end, libc::F_GETFL) };
unsafe { libc::fcntl(end, libc::F_SETFL, flags | libc::O_NONBLOCK) };
}
(ends[0], ends[1])
};
let overflowed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let max_reads_per_select = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let empty_since = std::sync::Arc::new(std::sync::Mutex::new(None));
let fences = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let parked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let thread_fired = std::sync::Arc::clone(&fired);
let thread_ended = std::sync::Arc::clone(&ended);
let thread_shutdown = std::sync::Arc::clone(&shutdown);
let thread_arrivals = std::sync::Arc::clone(&arrivals);
let thread_overflowed = std::sync::Arc::clone(&overflowed);
let thread_max_reads = std::sync::Arc::clone(&max_reads_per_select);
let thread_empty_since = std::sync::Arc::clone(&empty_since);
let thread_parked = std::sync::Arc::clone(&parked);
let reader = std::thread::Builder::new()
.name("rat-trigger".to_string())
.spawn(move || {
let _keep_alive = keep_alive;
trigger_read_loop(
ReaderFds {
data: fd,
control: control_rx,
drainable,
},
&ReaderState {
fired: &thread_fired,
ended: &thread_ended,
shutdown: &thread_shutdown,
parked: &thread_parked,
arrivals: &thread_arrivals,
overflowed: &thread_overflowed,
max_reads_per_select: &thread_max_reads,
empty_since: &thread_empty_since,
},
wake,
);
})?;
Ok(TriggerReader {
fired,
ended,
shutdown,
arrivals,
overflowed,
max_reads_per_select,
control: (control_rx, control_tx),
empty_since,
fences,
parked,
reader: Some(reader),
})
}
pub fn fired(&self) -> &std::sync::atomic::AtomicBool {
&self.fired
}
pub fn ended(&self) -> &std::sync::atomic::AtomicBool {
&self.ended
}
pub fn take_arrivals(&self) -> Vec<Observation> {
let mut queue = self
.arrivals
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
queue.drain(..).collect()
}
#[cfg(test)]
pub fn lock_arrivals_for_test(
&self,
) -> std::sync::MutexGuard<'_, std::collections::VecDeque<Observation>> {
self.arrivals
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
pub fn max_reads_per_select_for_test(&self) -> usize {
self.max_reads_per_select
.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn fence(&self) {
self.fences
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let byte = 1u8;
unsafe {
libc::write(self.control.1, std::ptr::addr_of!(byte).cast(), 1);
}
}
#[cfg(test)]
pub fn fences_for_test(&self) -> usize {
self.fences.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(test)]
pub fn empty_since_for_test(&self) -> Option<std::time::Instant> {
*self
.empty_since
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
pub fn park_for_test(&self) {
self.parked.store(true, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(test)]
pub fn control_read_end_open_for_test(&self) -> bool {
unsafe { libc::fcntl(self.control.0, libc::F_GETFD) != -1 }
}
pub fn overflowed(&self) -> bool {
self.overflowed
.swap(false, std::sync::atomic::Ordering::SeqCst)
}
}
#[cfg(unix)]
impl Drop for TriggerReader {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.shutdown.store(true, Ordering::SeqCst);
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
unsafe {
libc::close(self.control.0);
libc::close(self.control.1);
}
}
}
#[cfg(unix)]
fn readable_now(fd: i32) -> bool {
let mut set: libc::fd_set = unsafe { std::mem::zeroed() };
unsafe {
libc::FD_ZERO(&mut set);
libc::FD_SET(fd, &mut set);
}
let mut zero = libc::timeval {
tv_sec: 0,
tv_usec: 0,
};
unsafe {
libc::select(
fd + 1,
&mut set,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut zero,
) > 0
}
}
#[cfg(unix)]
enum ReadStep {
Got,
Idle,
Over,
}
#[cfg(unix)]
struct ReaderFds {
data: i32,
control: i32,
drainable: bool,
}
#[cfg(unix)]
struct ReaderState<'a> {
fired: &'a std::sync::atomic::AtomicBool,
ended: &'a std::sync::atomic::AtomicBool,
shutdown: &'a std::sync::atomic::AtomicBool,
parked: &'a std::sync::atomic::AtomicBool,
arrivals: &'a std::sync::Mutex<std::collections::VecDeque<Observation>>,
overflowed: &'a std::sync::atomic::AtomicBool,
max_reads_per_select: &'a std::sync::atomic::AtomicUsize,
empty_since: &'a std::sync::Mutex<Option<std::time::Instant>>,
}
#[cfg(unix)]
fn trigger_read_loop(
fds: ReaderFds,
state: &ReaderState<'_>,
wake: Option<std::sync::mpsc::Sender<TapChunk>>,
) {
use std::sync::atomic::Ordering;
let ReaderFds {
data: fd,
control,
drainable,
} = fds;
let &ReaderState {
fired,
ended,
shutdown,
parked,
arrivals,
overflowed,
max_reads_per_select,
empty_since: published,
} = state;
let mut buf = [0u8; 256];
let mut empty_since: Option<std::time::Instant> = None;
let publish = |proof: Option<std::time::Instant>| {
*published
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = proof;
};
loop {
if shutdown.load(Ordering::SeqCst) {
return;
}
if parked.load(Ordering::SeqCst) {
std::thread::sleep(READ_SLICE);
continue;
}
let mut read_set: libc::fd_set = unsafe { std::mem::zeroed() };
unsafe {
libc::FD_ZERO(&mut read_set);
libc::FD_SET(fd, &mut read_set);
libc::FD_SET(control, &mut read_set);
}
let mut timeout = libc::timeval {
tv_sec: 0,
tv_usec: READ_SLICE.subsec_micros() as libc::suseconds_t,
};
let candidate = std::time::Instant::now();
let ready = unsafe {
libc::select(
fd.max(control) + 1,
&mut read_set,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut timeout,
)
};
if ready < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
ended.store(true, Ordering::SeqCst);
return;
}
if ready == 0 {
empty_since = Some(candidate);
publish(empty_since);
continue;
}
if unsafe { libc::FD_ISSET(control, &read_set) } {
let mut sink = [0u8; 64];
while unsafe {
libc::read(
control,
sink.as_mut_ptr().cast::<libc::c_void>(),
sink.len(),
)
} > 0
{}
let candidate = std::time::Instant::now();
if !readable_now(fd) {
empty_since = Some(candidate);
publish(empty_since);
continue;
}
}
let mut reads = 0usize;
loop {
let read =
unsafe { libc::read(fd, buf.as_mut_ptr().cast::<libc::c_void>(), buf.len()) };
let step = if read > 0 {
ReadStep::Got
} else if read == 0 {
ReadStep::Over } else {
let err = std::io::Error::last_os_error();
if matches!(
err.kind(),
std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
) {
ReadStep::Idle
} else {
ReadStep::Over
}
};
match step {
ReadStep::Over => {
ended.store(true, Ordering::SeqCst);
return;
}
ReadStep::Idle => break,
ReadStep::Got => {}
}
reads += 1;
let observed_at = std::time::Instant::now();
{
let mut queue = arrivals
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if queue.len() == ARRIVAL_CAP {
queue.pop_front();
overflowed.store(true, Ordering::SeqCst);
}
queue.push_back(Observation {
empty_since,
observed_at,
});
}
if !drainable {
break; }
let candidate = std::time::Instant::now();
if !readable_now(fd) {
empty_since = Some(candidate);
publish(empty_since);
break;
}
}
max_reads_per_select.fetch_max(reads, Ordering::SeqCst);
if reads > 0
&& !fired.swap(true, Ordering::SeqCst)
&& let Some(wake) = wake.as_ref()
{
let _ = wake.send(TapChunk::Trigger);
}
}
}
#[cfg(all(test, unix))]
mod trigger_reader_tests {
use std::io::Write;
use std::os::unix::io::AsRawFd;
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::*;
use crate::core::trigger::TriggerSpec;
fn mkfifo(path: &std::path::Path) {
let cpath = std::ffi::CString::new(path.as_os_str().as_encoded_bytes().to_vec()).unwrap();
assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }, 0, "mkfifo");
}
fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
#[test]
fn a_fifo_write_raises_the_fired_flag() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.fifo");
mkfifo(&path);
let reader = TriggerReader::open(&TriggerSpec::Fifo(path.clone()), None).unwrap();
let mut writer = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
writer.write_all(b"x").unwrap();
assert!(
wait_until(|| reader.fired().swap(false, Ordering::SeqCst)),
"the write never raised the flag"
);
}
#[test]
fn a_writerless_fifo_does_not_spin_or_end() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.fifo");
mkfifo(&path);
let reader = TriggerReader::open(&TriggerSpec::Fifo(path), None).unwrap();
std::thread::sleep(Duration::from_millis(200));
assert!(!reader.ended().load(Ordering::SeqCst));
assert!(!reader.fired().load(Ordering::SeqCst));
}
#[test]
fn a_regular_file_fd_is_rejected_at_open_with_the_teaching_error() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("reg");
std::fs::write(&f, b"x").unwrap();
let file = std::fs::File::open(&f).unwrap();
let err = TriggerReader::open(&TriggerSpec::Fd(file.as_raw_fd()), None)
.unwrap_err()
.to_string();
assert!(err.contains("file:"), "{err}"); }
fn poke(reader: &TriggerReader, writer: &mut std::fs::File) {
reader.fired().store(false, Ordering::SeqCst);
writer.write_all(b"x").unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if reader.fired().load(Ordering::SeqCst) {
return;
}
std::thread::sleep(Duration::from_micros(200));
}
panic!("the write never reached the reader");
}
fn fifo_pair(dir: &std::path::Path) -> (TriggerReader, std::fs::File) {
let path = dir.join("t.fifo");
mkfifo(&path);
let reader = TriggerReader::open(&TriggerSpec::Fifo(path.clone()), None).unwrap();
let writer = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
(reader, writer)
}
#[test]
fn each_fire_records_one_arrival_instant() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
writer.write_all(b"x").unwrap();
assert!(
wait_until(|| reader.fired().load(Ordering::SeqCst)),
"the write never raised the flag"
);
assert_eq!(reader.take_arrivals().len(), 1);
}
#[test]
fn the_stamp_is_the_readers_not_the_loops() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
let before = std::time::Instant::now();
writer.write_all(b"x").unwrap();
assert!(wait_until(|| reader.fired().load(Ordering::SeqCst)));
std::thread::sleep(Duration::from_millis(300));
let arrivals = reader.take_arrivals();
assert_eq!(arrivals.len(), 1);
let at = arrivals[0].observed_at;
assert!(
at.duration_since(before) < Duration::from_millis(250),
"recorded {:?} after the write — taken at the drain, not at arrival",
at.duration_since(before)
);
assert!(
at.elapsed() >= Duration::from_millis(250),
"only {:?} before the drain; the sleep did not separate them",
at.elapsed()
);
}
#[test]
fn taking_arrivals_does_not_disturb_the_fired_flag() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
writer.write_all(b"x").unwrap();
assert!(wait_until(|| reader.fired().load(Ordering::SeqCst)));
let _ = reader.take_arrivals();
assert!(
reader.fired().load(Ordering::SeqCst),
"taking arrivals cleared the gate's flag"
);
}
#[test]
fn every_separately_observed_write_records_its_own_arrival() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
for _ in 0..5 {
poke(&reader, &mut writer);
}
assert_eq!(reader.take_arrivals().len(), 5);
}
#[test]
fn a_tight_burst_coalesces_and_that_is_the_safe_direction() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
for _ in 0..20 {
writer.write_all(b"x").unwrap();
}
assert!(wait_until(|| reader.fired().load(Ordering::SeqCst)));
std::thread::sleep(Duration::from_millis(200));
let n = reader.take_arrivals().len();
assert!(
(1..=20).contains(&n),
"{n} arrivals from 20 writes — more than 20 is impossible, \
and zero would mean the burst was lost entirely"
);
}
#[test]
fn the_queue_is_bounded_and_a_drop_is_reported_not_silent() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
for _ in 0..(ARRIVAL_CAP * 2) {
poke(&reader, &mut writer);
}
assert!(reader.overflowed(), "a drop must be observable");
assert!(
reader.take_arrivals().len() <= ARRIVAL_CAP,
"the queue grew past its bound"
);
}
#[test]
fn overflowed_reports_and_clears() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
for _ in 0..(ARRIVAL_CAP + 1) {
poke(&reader, &mut writer);
}
assert!(reader.overflowed());
assert!(!reader.overflowed(), "the flag did not clear on read");
}
#[test]
fn fd_eof_sets_ended_and_the_thread_exits() {
let mut fds = [0i32; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe");
let (r, w) = (fds[0], fds[1]);
let reader = TriggerReader::open(&TriggerSpec::Fd(r), None).unwrap();
unsafe { libc::close(w) };
assert!(
wait_until(|| reader.ended().load(Ordering::SeqCst)),
"EOF never set ended"
);
}
fn wait_for_empty_proof(reader: &TriggerReader) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if reader.empty_since_for_test().is_some() {
return;
}
std::thread::sleep(Duration::from_millis(2));
}
panic!("the reader never proved its descriptor empty");
}
fn wait_for_observations(reader: &TriggerReader, n: usize) -> Vec<Observation> {
let mut out = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
out.extend(reader.take_arrivals());
if out.len() >= n {
return out;
}
std::thread::sleep(Duration::from_micros(200));
}
panic!("wanted {n} observations, saw {}", out.len());
}
fn os_pipe_pair() -> (std::fs::File, std::fs::File) {
use std::os::unix::io::FromRawFd;
let mut fds = [0i32; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe");
unsafe {
(
std::fs::File::from_raw_fd(fds[0]),
std::fs::File::from_raw_fd(fds[1]),
)
}
}
#[test]
fn an_observation_is_bounded_below_by_a_proof_of_emptiness() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
wait_for_empty_proof(&reader);
let before = std::time::Instant::now();
writer.write_all(b"x").unwrap();
let observations = wait_for_observations(&reader, 1);
let o = observations[0];
assert!(
o.empty_since.is_some(),
"the reader must report a lower bound"
);
assert!(
o.empty_since.unwrap() <= before,
"the proof of emptiness must precede the write it bounds"
);
assert!(o.observed_at >= before, "and the read must follow it");
}
#[test]
fn the_stamp_is_taken_before_the_queue_lock() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
let held = reader.lock_arrivals_for_test();
let at_write = std::time::Instant::now();
writer.write_all(b"x").unwrap();
std::thread::sleep(Duration::from_millis(120));
drop(held);
let o = wait_for_observations(&reader, 1)[0];
assert!(
o.observed_at < at_write + Duration::from_millis(100),
"observed_at was taken after the lock, not after the read: {:?}",
o.observed_at.duration_since(at_write)
);
}
#[test]
fn a_burst_drains_within_one_slice_and_shares_one_lower_bound() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
wait_for_empty_proof(&reader);
for _ in 0..3 {
writer.write_all(&[b'x'; 300]).unwrap(); }
let observations = wait_for_observations(&reader, 2);
let first = observations[0].empty_since;
assert!(first.is_some());
assert!(
observations.iter().all(|o| o.empty_since == first),
"one proof of emptiness bounds every chunk drained after it"
);
}
#[test]
fn an_fd_source_is_never_read_twice_in_one_select() {
let (rx, mut tx) = os_pipe_pair();
let reader = TriggerReader::open(&TriggerSpec::Fd(rx.as_raw_fd()), None).unwrap();
tx.write_all(&[b'x'; 700]).unwrap(); wait_for_observations(&reader, 3);
assert_eq!(
reader.max_reads_per_select_for_test(),
1,
"a fd: source must take exactly one read per select"
);
drop(tx);
}
#[test]
fn a_fence_proves_emptiness_without_waiting_for_a_slice() {
let dir = tempfile::tempdir().unwrap();
let (reader, _writer) = fifo_pair(dir.path());
std::thread::sleep(READ_SLICE + Duration::from_millis(10));
let before = std::time::Instant::now();
reader.fence();
std::thread::sleep(Duration::from_millis(5));
let proof = reader.empty_since_for_test();
assert!(
proof.is_some_and(|p| p >= before),
"the fence must produce a fresh proof of emptiness inside 5ms, \
not at the next 50ms slice"
);
}
#[test]
fn fence_never_blocks_even_when_the_reader_is_not_listening() {
let dir = tempfile::tempdir().unwrap();
let (reader, _writer) = fifo_pair(dir.path());
reader.park_for_test();
let start = std::time::Instant::now();
for _ in 0..200_000 {
reader.fence();
}
assert!(
start.elapsed() < Duration::from_secs(5),
"fence() blocked or backed up: {:?}",
start.elapsed()
);
assert_eq!(reader.fences_for_test(), 200_000);
}
#[test]
fn a_fence_that_loses_the_race_costs_precision_and_not_correctness() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
wait_for_empty_proof(&reader);
let at_write = std::time::Instant::now();
writer.write_all(b"x").unwrap();
reader.fence();
let o = wait_for_observations(&reader, 1)[0];
assert!(
o.empty_since.is_some_and(|p| p <= at_write),
"a proof of emptiness must never postdate the write it bounds"
);
}
#[test]
fn fencing_a_reader_whose_thread_has_ended_is_still_safe() {
let (rx, tx) = os_pipe_pair();
let reader = TriggerReader::open(&TriggerSpec::Fd(rx.as_raw_fd()), None).unwrap();
drop(tx); assert!(
wait_until(|| reader.ended().load(Ordering::SeqCst)),
"the reader never noticed EOF"
);
assert!(
reader.control_read_end_open_for_test(),
"the exiting thread closed the control read end; its number is \
now reusable and every later fence corrupts whatever claims it"
);
for _ in 0..100 {
reader.fence();
}
assert_eq!(reader.fences_for_test(), 100);
}
#[test]
fn a_fifo_source_does_drain_within_one_select() {
let dir = tempfile::tempdir().unwrap();
let (reader, mut writer) = fifo_pair(dir.path());
wait_for_empty_proof(&reader);
writer.write_all(&[b'x'; 700]).unwrap();
wait_for_observations(&reader, 3);
assert!(
reader.max_reads_per_select_for_test() > 1,
"an owned non-blocking fifo drains while readable"
);
}
}