diff --git a/crates/cli/src/agent.rs b/crates/cli/src/agent.rs index fc0f67e6..3fbf1f1a 100644 --- a/crates/cli/src/agent.rs +++ b/crates/cli/src/agent.rs @@ -218,6 +218,16 @@ impl AgentConn { Ok(data) } + pub(crate) async fn recv_unbounded(&mut self) -> Result, String> { + let data = read_message(&mut self.reader, &mut self.fragment_buf) + .await + .ok_or_else(|| "server closed connection".to_string())?; + if let Some(error) = self.note_kick(&data) { + return Err(error); + } + Ok(data) + } + pub(crate) fn has_pty(&self, id: u16) -> bool { self.ptys.iter().any(|p| p.id == id) } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 42c1aff2..522945cc 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -358,6 +358,12 @@ pub enum Command { command: KvCommand, }, + /// Configure and export structured server events (docs/design/events.md) + Events { + #[command(subcommand)] + command: EventsCommand, + }, + /// Query language servers on the server (docs/design/lsp.md) /// /// Language servers are discovered by project markers (Cargo.toml, @@ -2091,6 +2097,108 @@ pub enum GitCommand { }, } +// ── Events subcommands ─────────────────────────────────────────────────── + +#[derive(Subcommand, Debug)] +pub enum EventsCommand { + /// Show or change ring configuration + Config { + #[command(subcommand)] + command: Option, + + /// JSON output + #[arg(long, global = true)] + json: bool, + }, + + /// Write a bounded ring snapshot as a canonical event file + Dump { + /// First sequence to include (default: oldest retained) + #[arg(long, value_name = "SEQ", default_value_t = 0)] + since: u64, + + /// Maximum records to write + #[arg(long, value_name = "N", default_value_t = blit_remote::events::EVENTS_DUMP_MAX_RECORDS)] + limit: u32, + + /// Destination file, or - for stdout + #[arg(long, value_name = "PATH", default_value = "-")] + output: String, + }, + + /// Follow events and write a canonical event file + Stream { + /// Start cursor: now, oldest, or a sequence + #[arg(long, value_name = "now|oldest|SEQ", default_value = "now")] + since: String, + + /// Destination file, or - for stdout + #[arg(long, value_name = "PATH", default_value = "-")] + output: String, + }, + + /// Manage event files written by the server + File { + #[command(subcommand)] + command: EventsFileCommand, + }, +} + +#[derive(Subcommand, Debug)] +pub enum EventsConfigCommand { + /// Change ring capacity and/or active event ids + #[command(group( + clap::ArgGroup::new("change") + .required(true) + .multiple(true) + .args(["bytes", "active"]) + ))] + Set { + /// Ring capacity in bytes (supports KiB, MiB, GiB) + #[arg(long, value_name = "SIZE")] + bytes: Option, + + /// Comma-separated selectors, or a 128-bit hexadecimal mask + #[arg(long, value_name = "SELECTORS|HEX")] + active: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum EventsFileCommand { + /// Start a server-side canonical event file + Start { + /// Path on the server + path: String, + + /// Append to an existing canonical file + #[arg(long)] + append: bool, + + /// Synchronize records durably as they are written + #[arg(long)] + sync: bool, + + /// Stream id (random when omitted) + #[arg(long)] + id: Option, + + /// JSON output + #[arg(long)] + json: bool, + }, + + /// Stop a server-side event file + Stop { + /// Stream id returned by start + id: u32, + + /// JSON output + #[arg(long)] + json: bool, + }, +} + // ── Kv subcommands ─────────────────────────────────────────────────────── #[derive(Subcommand)] @@ -2473,6 +2581,43 @@ mod tests { deployment.into_overrides().unwrap(); } + #[test] + fn events_surface_parses() { + let cli = Cli::try_parse_from([ + "blit", + "events", + "config", + "set", + "--bytes", + "1MiB", + "--active", + "pty,+task-failed", + "--json", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Command::Events { + command: EventsCommand::Config { + command: Some(EventsConfigCommand::Set { .. }), + json: true, + } + } + )); + + for args in [ + vec!["blit", "events", "dump", "--since", "42", "--output", "-"], + vec!["blit", "events", "stream", "--since", "oldest"], + vec![ + "blit", "events", "file", "start", "/tmp/e", "--append", "--sync", + ], + vec!["blit", "events", "file", "stop", "7", "--json"], + ] { + assert!(Cli::try_parse_from(args).is_ok()); + } + assert!(Cli::try_parse_from(["blit", "events", "config", "set"]).is_err()); + } + #[test] fn server_name_defaults_and_is_validated() { let cli = Cli::try_parse_from(["blit", "server", "--name", "work-tree.2"]).unwrap(); diff --git a/crates/cli/src/events.rs b/crates/cli/src/events.rs new file mode 100644 index 00000000..eb599408 --- /dev/null +++ b/crates/cli/src/events.rs @@ -0,0 +1,667 @@ +use std::io::ErrorKind; + +use blit_remote::events::{ + Activation, C2S_CONFIG_GET, C2S_CONFIG_SET, C2S_DUMP, C2S_FILE_START, C2S_FILE_STOP, + C2S_STREAM_START, C2S_STREAM_STOP, EVENT_NAMES, EVENT_RECORD_SIZE, EVENTS, + EVENTS_DUMP_MAX_RECORDS, EVENTS_RING_MAX, EVENTS_RING_MIN, EventConfig, EventFileHeader, + EventMessage, FEATURE_EVENTS, FILE_APPEND, FILE_SYNC, STREAM_FOLLOW, event_name, + msg_config_get, msg_config_set, msg_dump, msg_file_start, msg_file_stop, msg_stream_start, + msg_stream_stop, parse_event_message, +}; +use blit_remote::{STATUS_BUDGET, STATUS_OK, status_text}; +use tokio::io::AsyncWriteExt; + +use crate::agent::AgentConn; +use crate::transport::Transport; + +const REQUEST_CONFIG_GET: u32 = 1; +const REQUEST_CONFIG_SET: u32 = 2; +const REQUEST_DUMP: u32 = 3; +const REQUEST_STREAM_START: u32 = 4; +const REQUEST_STREAM_STOP: u32 = 5; +const REQUEST_FILE_START: u32 = 6; +const REQUEST_FILE_STOP: u32 = 7; + +fn require_feature(conn: &AgentConn) -> Result<(), String> { + if conn.features & FEATURE_EVENTS == 0 { + return Err("server has no structured events support (upgrade blit on the remote)".into()); + } + Ok(()) +} + +fn status_result(operation: &str, status: u8) -> Result<(), String> { + if status == STATUS_OK { + Ok(()) + } else { + Err(format!("{operation}: {}", status_text(status))) + } +} + +async fn recv_message(conn: &mut AgentConn) -> Result { + loop { + let packet = conn.recv().await?; + if packet.first() != Some(&EVENTS) { + continue; + } + return parse_event_message(&packet).map_err(|error| error.to_string()); + } +} + +async fn recv_message_unbounded(conn: &mut AgentConn) -> Result { + loop { + let packet = conn.recv_unbounded().await?; + if packet.first() != Some(&EVENTS) { + continue; + } + return parse_event_message(&packet).map_err(|error| error.to_string()); + } +} + +async fn get_config(conn: &mut AgentConn, request_id: u32) -> Result { + conn.send(&msg_config_get(request_id)).await?; + loop { + match recv_message(conn).await? { + EventMessage::Config { + request_id: reply_id, + status, + config, + } if reply_id == request_id => { + status_result("events config", status)?; + return Ok(config); + } + EventMessage::Status { + request_id: reply_id, + request_kind: C2S_CONFIG_GET, + status, + } if reply_id == request_id => { + return Err(format!("events config: {}", status_text(status))); + } + _ => {} + } + } +} + +async fn set_config( + conn: &mut AgentConn, + request_id: u32, + config: EventConfig, +) -> Result { + let packet = msg_config_set(request_id, config).map_err(|error| error.to_string())?; + conn.send(&packet).await?; + loop { + match recv_message(conn).await? { + EventMessage::Config { + request_id: reply_id, + status, + config, + } if reply_id == request_id => { + status_result("events config set", status)?; + return Ok(config); + } + EventMessage::Status { + request_id: reply_id, + request_kind: C2S_CONFIG_SET, + status, + } if reply_id == request_id => { + return Err(format!("events config set: {}", status_text(status))); + } + _ => {} + } + } +} + +fn activation_hex(activation: Activation) -> String { + activation + .0 + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn active_values(activation: Activation) -> Vec { + (0..128u8) + .filter(|id| activation.contains(*id)) + .map(|id| match event_name(id) { + Some(name) => serde_json::json!({ "id": id, "name": name }), + None => serde_json::json!({ "id": id }), + }) + .collect() +} + +fn print_config(config: EventConfig, json: bool) { + let bytes = config.ring_size as u64 * EVENT_RECORD_SIZE as u64; + let hex = activation_hex(config.activation); + if json { + println!( + "{}", + serde_json::json!({ + "bytes": bytes, + "records": config.ring_size, + "activation_hex": hex, + "active": active_values(config.activation), + }) + ); + return; + } + let active = (0..128u8) + .filter(|id| config.activation.contains(*id)) + .map(|id| match event_name(id) { + Some(name) => format!("{id}:{name}"), + None => id.to_string(), + }) + .collect::>() + .join(","); + println!("bytes\t{bytes}"); + println!("records\t{}", config.ring_size); + println!("activation_hex\t{hex}"); + println!("active\t{active}"); +} + +pub async fn cmd_config(transport: Transport, json: bool) -> Result<(), String> { + let mut conn = AgentConn::connect(transport).await?; + require_feature(&conn)?; + let config = get_config(&mut conn, REQUEST_CONFIG_GET).await?; + print_config(config, json); + Ok(()) +} + +pub async fn cmd_config_set( + transport: Transport, + bytes: Option, + active: Option, + json: bool, +) -> Result<(), String> { + if bytes.is_none() && active.is_none() { + return Err("events config set requires --bytes and/or --active".into()); + } + let mut conn = AgentConn::connect(transport).await?; + require_feature(&conn)?; + let current = if bytes.is_none() || active.is_none() { + Some(get_config(&mut conn, REQUEST_CONFIG_GET).await?) + } else { + None + }; + let ring_size = match bytes { + Some(value) => bytes_to_records(parse_bytes(&value)?)?, + None => current.expect("missing field reads config").ring_size, + }; + let activation = match active { + Some(value) => parse_activation(&value)?, + None => current.expect("missing field reads config").activation, + }; + let config = set_config( + &mut conn, + REQUEST_CONFIG_SET, + EventConfig::new(ring_size, activation).map_err(|error| error.to_string())?, + ) + .await?; + print_config(config, json); + Ok(()) +} + +fn parse_bytes(input: &str) -> Result { + let value = input.trim(); + let split = value + .find(|character: char| !character.is_ascii_digit()) + .unwrap_or(value.len()); + let number = value[..split] + .parse::() + .map_err(|_| format!("invalid byte size {input:?}"))?; + let multiplier = match value[split..].trim().to_ascii_lowercase().as_str() { + "" | "b" => 1, + "k" | "kb" | "kib" => 1024, + "m" | "mb" | "mib" => 1024 * 1024, + "g" | "gb" | "gib" => 1024 * 1024 * 1024, + suffix => return Err(format!("invalid byte-size suffix {suffix:?}")), + }; + number + .checked_mul(multiplier) + .ok_or_else(|| "byte size is too large".into()) +} + +fn bytes_to_records(bytes: u64) -> Result { + if !bytes.is_multiple_of(EVENT_RECORD_SIZE as u64) { + return Err(format!("--bytes must be a multiple of {EVENT_RECORD_SIZE}")); + } + let records = bytes / EVENT_RECORD_SIZE as u64; + if !(EVENTS_RING_MIN as u64..=EVENTS_RING_MAX as u64).contains(&records) { + return Err(format!( + "--bytes must select {EVENTS_RING_MIN}..={EVENTS_RING_MAX} records" + )); + } + Ok(records as u32) +} + +fn parse_activation(input: &str) -> Result { + let value = input.trim(); + let hex = value.strip_prefix("0x").unwrap_or(value); + if hex.len() == 32 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + let mut bytes = [0; 16]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16) + .map_err(|_| "invalid activation hex".to_string())?; + } + return Ok(Activation(bytes)); + } + + let selectors = value + .split([',', ' ', '\t', '\n']) + .filter(|selector| !selector.is_empty()) + .collect::>(); + if selectors.is_empty() { + return Err("--active must contain a selector or 32 hexadecimal digits".into()); + } + let mut activation = Activation::NONE; + for raw in selectors { + let (enabled, selector) = match raw.as_bytes()[0] { + b'+' => (true, &raw[1..]), + b'-' => (false, &raw[1..]), + _ => (true, raw), + }; + if selector.is_empty() { + return Err("empty event selector".into()); + } + match selector.replace('_', "-").to_ascii_lowercase().as_str() { + "all" => { + activation = if enabled { + Activation::ALL + } else { + Activation::NONE + } + } + "none" => { + activation = if enabled { + Activation::NONE + } else { + Activation::ALL + } + } + selector => { + let ids = selector_ids(selector) + .ok_or_else(|| format!("unknown event selector {selector:?}"))?; + for id in ids { + activation.set(id, enabled); + } + } + } + } + Ok(activation) +} + +fn selector_ids(selector: &str) -> Option> { + if let Ok(id) = selector.parse::() { + return Some(vec![id]); + } + if let Some((id, _)) = EVENT_NAMES.iter().find(|(_, name)| *name == selector) { + return Some(vec![*id]); + } + let range = match selector { + "server" | "lifecycle" => 0..=7, + "client" | "clients" => 8..=15, + "request" | "requests" | "raw-request" | "raw-requests" => 16..=23, + "writer" | "writers" => 24..=31, + "pty" | "pty-create" => 32..=55, + "process" | "processes" => 56..=63, + "compositor" => 64..=67, + "surface" | "surfaces" => 68..=71, + "protocol" | "protocols" | "integration" | "integrations" => 72..=103, + "task" | "tasks" => 104..=111, + "recorder" | "config" | "ring" | "stream" => 112..=127, + _ => return None, + }; + Some( + EVENT_NAMES + .iter() + .filter_map(|(id, _)| range.contains(id).then_some(*id)) + .collect(), + ) +} + +enum EventOutput { + Stdout(tokio::io::Stdout), + File(tokio::fs::File), +} + +impl EventOutput { + async fn open(path: &str) -> Result { + if path == "-" { + Ok(Self::Stdout(tokio::io::stdout())) + } else { + tokio::fs::File::create(path) + .await + .map(Self::File) + .map_err(|error| format!("{path}: {error}")) + } + } + + async fn write(&mut self, bytes: &[u8]) -> std::io::Result<()> { + match self { + Self::Stdout(output) => output.write_all(bytes).await, + Self::File(output) => output.write_all(bytes).await, + } + } + + async fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Stdout(output) => output.flush().await, + Self::File(output) => output.flush().await, + } + } +} + +async fn write_records( + output: &mut EventOutput, + records: &[blit_remote::events::EventRecord], +) -> std::io::Result<()> { + for record in records { + output.write(&record.encode()).await?; + } + Ok(()) +} + +pub async fn cmd_dump( + transport: Transport, + since: u64, + limit: u32, + output_path: String, +) -> Result<(), String> { + if limit == 0 || limit > EVENTS_DUMP_MAX_RECORDS { + return Err(format!("--limit must be in 1..={EVENTS_DUMP_MAX_RECORDS}")); + } + let mut conn = AgentConn::connect(transport).await?; + require_feature(&conn)?; + let packet = msg_dump(REQUEST_DUMP, since, limit).map_err(|error| error.to_string())?; + conn.send(&packet).await?; + loop { + match recv_message(&mut conn).await? { + EventMessage::Dump { + request_id: REQUEST_DUMP, + status, + records, + .. + } => { + if status != STATUS_OK && status != STATUS_BUDGET { + return Err(format!("events dump: {}", status_text(status))); + } + if status == STATUS_BUDGET { + eprintln!("blit: events dump starts after records no longer retained"); + } + let mut output = EventOutput::open(&output_path).await?; + output + .write(&EventFileHeader::CANONICAL.encode()) + .await + .map_err(|error| format!("{output_path}: {error}"))?; + write_records(&mut output, &records) + .await + .map_err(|error| format!("{output_path}: {error}"))?; + output + .flush() + .await + .map_err(|error| format!("{output_path}: {error}"))?; + return Ok(()); + } + EventMessage::Status { + request_id: REQUEST_DUMP, + request_kind: C2S_DUMP, + status, + } => return Err(format!("events dump: {}", status_text(status))), + _ => {} + } + } +} + +fn parse_stream_cursor(value: &str) -> Result { + match value { + "now" => Ok(u64::MAX), + "oldest" => Ok(0), + _ => value + .parse() + .map_err(|_| format!("invalid event cursor {value:?} (want now, oldest, or SEQ)")), + } +} + +async fn stop_stream(conn: &mut AgentConn, stream_id: u32) -> Result<(), String> { + conn.send(&msg_stream_stop(REQUEST_STREAM_STOP, stream_id)) + .await?; + loop { + match recv_message(conn).await? { + EventMessage::StreamStatus { + request_id: REQUEST_STREAM_STOP, + status, + stream_id: reply_stream, + .. + } if reply_stream == stream_id => return status_result("events stream stop", status), + EventMessage::Status { + request_id: REQUEST_STREAM_STOP, + request_kind: C2S_STREAM_STOP, + status, + } => return Err(format!("events stream stop: {}", status_text(status))), + _ => {} + } + } +} + +pub async fn cmd_stream( + transport: Transport, + since: String, + output_path: String, +) -> Result<(), String> { + let cursor = parse_stream_cursor(&since)?; + let stream_id = random_id(); + let mut conn = AgentConn::connect(transport).await?; + require_feature(&conn)?; + let packet = msg_stream_start(REQUEST_STREAM_START, stream_id, cursor, STREAM_FOLLOW) + .map_err(|error| error.to_string())?; + conn.send(&packet).await?; + loop { + match recv_message(&mut conn).await? { + EventMessage::StreamStatus { + request_id: REQUEST_STREAM_START, + status, + stream_id: reply_stream, + .. + } if reply_stream == stream_id => { + status_result("events stream", status)?; + break; + } + EventMessage::Status { + request_id: REQUEST_STREAM_START, + request_kind: C2S_STREAM_START, + status, + } => return Err(format!("events stream: {}", status_text(status))), + _ => {} + } + } + + let mut output = EventOutput::open(&output_path).await?; + if let Err(error) = output.write(&EventFileHeader::CANONICAL.encode()).await { + let _ = stop_stream(&mut conn, stream_id).await; + if error.kind() == ErrorKind::BrokenPipe { + return Ok(()); + } + return Err(format!("{output_path}: {error}")); + } + + loop { + tokio::select! { + result = recv_message_unbounded(&mut conn) => { + match result? { + EventMessage::StreamData { stream_id: reply_stream, records, .. } + if reply_stream == stream_id => + { + if let Err(error) = write_records(&mut output, &records).await { + let _ = stop_stream(&mut conn, stream_id).await; + if error.kind() == ErrorKind::BrokenPipe { + return Ok(()); + } + return Err(format!("{output_path}: {error}")); + } + } + EventMessage::StreamStatus { request_id: 0, stream_id: reply_stream, status, .. } + if reply_stream == stream_id && status == STATUS_BUDGET => + { + eprintln!("blit: event stream gap: records were overwritten"); + } + EventMessage::StreamStatus { request_id: 0, stream_id: reply_stream, status, .. } + if reply_stream == stream_id && status != STATUS_OK => + { + return Err(format!("events stream: {}", status_text(status))); + } + _ => {} + } + } + _ = tokio::signal::ctrl_c() => { + let _ = stop_stream(&mut conn, stream_id).await; + let _ = output.flush().await; + return Ok(()); + } + } + } +} + +fn random_id() -> u32 { + loop { + let id = rand::random(); + if id != 0 { + return id; + } + } +} + +async fn recv_file_status( + conn: &mut AgentConn, + request_id: u32, + request_kind: u8, + stream_id: u32, +) -> Result<(u64, u64, String), String> { + loop { + match recv_message(conn).await? { + EventMessage::FileStatus { + request_id: reply_id, + status, + stream_id: reply_stream, + records_written, + bytes_written, + detail, + } if reply_id == request_id && reply_stream == stream_id => { + status_result("events file", status)?; + return Ok((records_written, bytes_written, detail)); + } + EventMessage::Status { + request_id: reply_id, + request_kind: reply_kind, + status, + } if reply_id == request_id && reply_kind == request_kind => { + return Err(format!("events file: {}", status_text(status))); + } + _ => {} + } + } +} + +fn print_file_status(id: u32, records_written: u64, bytes_written: u64, detail: &str, json: bool) { + if json { + println!( + "{}", + serde_json::json!({ + "id": id, + "records_written": records_written, + "bytes_written": bytes_written, + "detail": detail, + }) + ); + } else { + println!("id\t{id}"); + println!("records_written\t{records_written}"); + println!("bytes_written\t{bytes_written}"); + if !detail.is_empty() { + println!("detail\t{detail}"); + } + } +} + +pub async fn cmd_file_start( + transport: Transport, + path: String, + append: bool, + sync: bool, + id: Option, + json: bool, +) -> Result<(), String> { + let stream_id = id.unwrap_or_else(random_id); + if stream_id == 0 { + return Err("event file id must not be zero".into()); + } + let mut conn = AgentConn::connect(transport).await?; + require_feature(&conn)?; + let flags = (if append { FILE_APPEND } else { 0 }) | (if sync { FILE_SYNC } else { 0 }); + let packet = msg_file_start(REQUEST_FILE_START, stream_id, flags, &path) + .map_err(|error| error.to_string())?; + conn.send(&packet).await?; + let (records, bytes, detail) = + recv_file_status(&mut conn, REQUEST_FILE_START, C2S_FILE_START, stream_id).await?; + print_file_status(stream_id, records, bytes, &detail, json); + Ok(()) +} + +pub async fn cmd_file_stop(transport: Transport, stream_id: u32, json: bool) -> Result<(), String> { + let mut conn = AgentConn::connect(transport).await?; + require_feature(&conn)?; + conn.send(&msg_file_stop(REQUEST_FILE_STOP, stream_id)) + .await?; + let (records, bytes, detail) = + recv_file_status(&mut conn, REQUEST_FILE_STOP, C2S_FILE_STOP, stream_id).await?; + print_file_status(stream_id, records, bytes, &detail, json); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use blit_remote::events::{EventRequest, parse_event_request}; + + #[test] + fn parses_bytes_and_activation_forms() { + assert_eq!(bytes_to_records(parse_bytes("1MiB").unwrap()), Ok(16_384)); + assert!(bytes_to_records(65).is_err()); + + let active = parse_activation("none,pty,+task-failed").unwrap(); + assert!(active.contains(32)); + assert!(active.contains(107)); + assert!(!active.contains(8)); + let hex = activation_hex(active); + assert_eq!(parse_activation(&hex), Ok(active)); + assert_eq!(parse_activation(&format!("0x{hex}")), Ok(active)); + assert!(parse_activation("unknown-event").is_err()); + } + + #[test] + fn cli_values_round_trip_through_protocol_codec() { + let activation = parse_activation("client,pty-exit,117").unwrap(); + let config = EventConfig::new( + bytes_to_records(parse_bytes("64KiB").unwrap()).unwrap(), + activation, + ) + .unwrap(); + let packet = msg_config_set(REQUEST_CONFIG_SET, config).unwrap(); + assert_eq!( + parse_event_request(&packet), + Ok(EventRequest::ConfigSet { + request_id: REQUEST_CONFIG_SET, + config, + }) + ); + + let cursor = parse_stream_cursor("now").unwrap(); + let packet = msg_stream_start(91, 92, cursor, STREAM_FOLLOW).unwrap(); + assert!(matches!( + parse_event_request(&packet), + Ok(EventRequest::StreamStart { + request_id: 91, + stream_id: 92, + from_sequence: u64::MAX, + flags: STREAM_FOLLOW, + }) + )); + } +} diff --git a/crates/cli/src/learn.md b/crates/cli/src/learn.md index 75d24d2c..c7e465b5 100644 --- a/crates/cli/src/learn.md +++ b/crates/cli/src/learn.md @@ -250,6 +250,38 @@ Writes are compare-and-swap when you ask: `--if-hash H` writes only if the current value still hashes to H, exiting 1 on conflict. Without it a put is an unconditional overwrite. `--durable` waits for disk. +## Structured events + +Configure the bounded server event ring and export its fixed binary records. +Every dump and stream output is a canonical `blit.events.v1` file: a 32-byte +header followed by 64-byte records. + +```bash +blit events config +blit events config --json +blit events config set --bytes 4MiB +blit events config set --active pty,process,+task-failed +blit events config set --active 1f000000001000000000000000003f00 --json +blit events dump --since 42 --limit 1000 --output events.blit +blit events stream --since now --output - >events.blit +blit events stream --since oldest --output events.blit +blit events file start /tmp/server-events.blit --append --sync --json +blit events file stop ID --json +``` + +`config set` preserves the current field when only `--bytes` or `--active` is +supplied. Byte sizes must be multiples of 64 and may use `KiB`, `MiB`, or +`GiB`. Activation accepts named event ids, numeric ids, or families such as +`server`, `client`, `request`, `writer`, `pty`, `process`, `compositor`, +`surface`, `protocol`, `task`, and `recorder`; comma-separated `+` and `-` +selectors add and remove ids. A hexadecimal activation is exactly 16 bytes +(32 hexadecimal digits, optional `0x`). + +`stream` follows until Ctrl-C. `--since now` starts at the live edge, +`--since oldest` replays retained records first, and a sequence resumes a +saved cursor. `file start` writes on the server, so its path is server-local; +the returned id is passed to `file stop`. + ## Wasmi extensions ```bash diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3e0b3c30..3bc33654 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,6 +2,7 @@ mod agent; mod attach; mod cli; mod completion; +mod events; mod extension; mod forward; mod fs; @@ -20,8 +21,9 @@ mod uplink; use clap::Parser; use cli::{ - Cli, ClientCommand, ClipboardCommand, Command, FsCommand, GitCommand, KvCommand, LspCommand, - RemoteCommand, SurfaceCommand, TerminalCommand, + Cli, ClientCommand, ClipboardCommand, Command, EventsCommand, EventsConfigCommand, + EventsFileCommand, FsCommand, GitCommand, KvCommand, LspCommand, RemoteCommand, SurfaceCommand, + TerminalCommand, }; // glibc malloc retains freed memory in per-thread arenas (up to 8 per core); @@ -863,6 +865,48 @@ async fn async_main() { } } } + Command::Events { command } => { + let conn = &cli.connect; + let transport = match transport::connect(&conn.on, &conn.hub).await { + Ok(transport) => transport, + Err(error) => { + eprintln!("blit: {error}"); + std::process::exit(1); + } + }; + let result = match command { + EventsCommand::Config { command, json } => match command { + None => events::cmd_config(transport, json).await, + Some(EventsConfigCommand::Set { bytes, active }) => { + events::cmd_config_set(transport, bytes, active, json).await + } + }, + EventsCommand::Dump { + since, + limit, + output, + } => events::cmd_dump(transport, since, limit, output).await, + EventsCommand::Stream { since, output } => { + events::cmd_stream(transport, since, output).await + } + EventsCommand::File { command } => match command { + EventsFileCommand::Start { + path, + append, + sync, + id, + json, + } => events::cmd_file_start(transport, path, append, sync, id, json).await, + EventsFileCommand::Stop { id, json } => { + events::cmd_file_stop(transport, id, json).await + } + }, + }; + if let Err(error) = result { + eprintln!("blit: {error}"); + std::process::exit(1); + } + } Command::Lsp { command } => { let conn = &cli.connect; let transport = match transport::connect(&conn.on, &conn.hub).await { diff --git a/crates/remote/src/events.rs b/crates/remote/src/events.rs new file mode 100644 index 00000000..13ee549a --- /dev/null +++ b/crates/remote/src/events.rs @@ -0,0 +1,1310 @@ +//! Versioned structured-event wire and file codec (`blit.events.v1`). +//! +//! The family is deliberately self-contained: every remote packet uses the +//! direction-local [`EVENTS`] opcode and carries a version, kind, flags, and +//! request id. Event records have the same fixed representation on the wire and +//! in files, so a dump can be written without translating records. + +use std::fmt; + +/// Direction-local `blit.events.v1` envelope opcode. +pub const EVENTS: u8 = 0x96; +/// `S2C_HELLO` feature bit for this family. +pub const FEATURE_EVENTS: u32 = 1 << 31; +/// Version in every remote envelope and canonical file header. +pub const EVENTS_VERSION: u8 = 1; +/// Bytes in the common packet envelope. +pub const EVENTS_HEADER_SIZE: usize = 8; + +pub const C2S_CONFIG_GET: u8 = 1; +pub const C2S_CONFIG_SET: u8 = 2; +pub const C2S_DUMP: u8 = 3; +pub const C2S_STREAM_START: u8 = 4; +pub const C2S_STREAM_STOP: u8 = 5; +pub const C2S_FILE_START: u8 = 6; +pub const C2S_FILE_STOP: u8 = 7; +/// Atomically replace the configuration only when it still matches an expected value. +pub const C2S_CONFIG_SET_IF: u8 = 8; + +pub const S2C_STATUS: u8 = 0; +pub const S2C_CONFIG: u8 = 1; +pub const S2C_DUMP: u8 = 2; +pub const S2C_STREAM_STATUS: u8 = 3; +pub const S2C_STREAM_DATA: u8 = 4; +pub const S2C_FILE_STATUS: u8 = 5; + +/// Continue sending newly appended records after replaying available records. +pub const STREAM_FOLLOW: u8 = 1 << 0; +pub const STREAM_FLAGS: u8 = STREAM_FOLLOW; +/// Open an existing event file for append rather than replacing it. +pub const FILE_APPEND: u8 = 1 << 0; +/// Request durable synchronization as records are written. +pub const FILE_SYNC: u8 = 1 << 1; +pub const FILE_FLAGS: u8 = FILE_APPEND | FILE_SYNC; + +pub const EVENTS_RING_MIN: u32 = 1; +pub const EVENTS_RING_MAX: u32 = 1_048_576; +pub const EVENTS_DUMP_MAX_RECORDS: u32 = 65_536; +pub const EVENTS_STREAM_MAX_RECORDS: usize = 65_536; +pub const EVENTS_PATH_MAX: usize = 4096; +pub const EVENTS_DETAIL_MAX: usize = 4096; + +/// Exact bytes occupied by one binary event. +pub const EVENT_RECORD_SIZE: usize = 64; +/// Exact bytes occupied by the canonical file header. +pub const EVENT_FILE_HEADER_SIZE: usize = 32; +pub const EVENT_FILE_MAGIC: [u8; 16] = *b"blit.events.v1\0\0"; + +/// Which event ids the server should retain. Bit `n` activates event id `n`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Activation(pub [u8; 16]); + +impl Activation { + pub const NONE: Self = Self([0; 16]); + pub const ALL: Self = Self([0xff; 16]); + + pub fn contains(self, event_id: u8) -> bool { + self.0[event_id as usize / 8] & (1 << (event_id % 8)) != 0 + } + + pub fn set(&mut self, event_id: u8, active: bool) { + let byte = &mut self.0[event_id as usize / 8]; + let mask = 1 << (event_id % 8); + if active { + *byte |= mask; + } else { + *byte &= !mask; + } + } +} + +/// Stable ids with public names. Unlisted ids remain valid activation bits and +/// are rendered numerically by clients. +pub const EVENT_NAMES: &[(u8, &str)] = &[ + (0, "server-starting"), + (1, "server-started"), + (2, "server-stopping"), + (3, "server-stopped"), + (4, "server-error"), + (8, "client-connected"), + (9, "client-ready"), + (10, "client-disconnecting"), + (11, "client-disconnected"), + (12, "client-error"), + (16, "raw-request-read"), + (17, "raw-request-dispatch"), + (18, "raw-request-done"), + (19, "raw-request-reject"), + (24, "writer-dequeue"), + (25, "writer-write-begin"), + (26, "writer-write-end"), + (27, "writer-error"), + (28, "writer-backpressure"), + (32, "pty-create-request"), + (33, "pty-create-mutex-acquired"), + (34, "pty-create-spawn-begin"), + (35, "pty-create-spawn-end"), + (36, "pty-create-registered"), + (37, "pty-create-reply-queued"), + (38, "pty-create-error"), + (40, "pty-read"), + (41, "pty-queue"), + (42, "pty-drain"), + (43, "pty-parse"), + (44, "pty-frame-queued"), + (45, "pty-input"), + (46, "pty-resize"), + (47, "pty-exit"), + (48, "pty-evict"), + (49, "pty-io-error"), + (56, "process-request"), + (57, "process-spawn"), + (58, "process-result"), + (59, "process-io"), + (60, "process-exit"), + (61, "process-error"), + (64, "compositor-started"), + (65, "compositor-stopped"), + (66, "compositor-error"), + (68, "surface-created"), + (69, "surface-destroyed"), + (70, "surface-frame-queued"), + (71, "surface-error"), + (72, "protocol-core"), + (73, "protocol-pty"), + (74, "protocol-process"), + (75, "protocol-compositor"), + (76, "protocol-surface"), + (77, "protocol-input"), + (78, "protocol-clipboard"), + (79, "protocol-filesystem"), + (80, "protocol-network"), + (81, "protocol-kv"), + (82, "protocol-browser"), + (83, "protocol-audio"), + (84, "protocol-events"), + (85, "protocol-integration"), + (87, "protocol-error"), + (104, "task-spawned"), + (105, "task-completed"), + (106, "task-cancelled"), + (107, "task-failed"), + (112, "config-changed"), + (113, "config-error"), + (114, "ring-dropped"), + (115, "ring-overwritten"), + (116, "stream-gap"), + (117, "stream-error"), +]; + +pub fn event_name(event_id: u8) -> Option<&'static str> { + EVENT_NAMES + .iter() + .find_map(|(id, name)| (*id == event_id).then_some(*name)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventConfig { + pub ring_size: u32, + pub activation: Activation, +} + +impl EventConfig { + pub fn new(ring_size: u32, activation: Activation) -> Result { + validate_ring_size(ring_size)?; + Ok(Self { + ring_size, + activation, + }) + } +} + +/// The stable 64-byte event representation shared by remote packets and files. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct EventRecord { + pub sequence: u64, + pub monotonic_ns: u64, + pub event_id: u32, + pub flags: u16, + pub source: u8, + pub schema: u8, + pub connection: u64, + pub subject: u64, + pub args: [u64; 3], +} + +impl EventRecord { + pub fn encode(self) -> [u8; EVENT_RECORD_SIZE] { + let mut out = [0; EVENT_RECORD_SIZE]; + out[0..8].copy_from_slice(&self.sequence.to_le_bytes()); + out[8..16].copy_from_slice(&self.monotonic_ns.to_le_bytes()); + out[16..20].copy_from_slice(&self.event_id.to_le_bytes()); + out[20..22].copy_from_slice(&self.flags.to_le_bytes()); + out[22] = self.source; + out[23] = self.schema; + out[24..32].copy_from_slice(&self.connection.to_le_bytes()); + out[32..40].copy_from_slice(&self.subject.to_le_bytes()); + out[40..48].copy_from_slice(&self.args[0].to_le_bytes()); + out[48..56].copy_from_slice(&self.args[1].to_le_bytes()); + out[56..64].copy_from_slice(&self.args[2].to_le_bytes()); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != EVENT_RECORD_SIZE { + return Err(EventCodecError::invalid(None)); + } + Ok(Self { + sequence: le_u64(&bytes[0..8]), + monotonic_ns: le_u64(&bytes[8..16]), + event_id: le_u32(&bytes[16..20]), + flags: le_u16(&bytes[20..22]), + source: bytes[22], + schema: bytes[23], + connection: le_u64(&bytes[24..32]), + subject: le_u64(&bytes[32..40]), + args: [ + le_u64(&bytes[40..48]), + le_u64(&bytes[48..56]), + le_u64(&bytes[56..64]), + ], + }) + } +} + +/// Canonical prefix for a file containing consecutive [`EventRecord`] bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventFileHeader; + +impl EventFileHeader { + pub const CANONICAL: Self = Self; + + pub fn encode(self) -> [u8; EVENT_FILE_HEADER_SIZE] { + let mut out = [0; EVENT_FILE_HEADER_SIZE]; + out[..16].copy_from_slice(&EVENT_FILE_MAGIC); + out[16] = EVENTS_VERSION; + out[18..20].copy_from_slice(&(EVENT_FILE_HEADER_SIZE as u16).to_le_bytes()); + out[20..22].copy_from_slice(&(EVENT_RECORD_SIZE as u16).to_le_bytes()); + out + } + + /// Parse the prefix of a file. Reserved bytes must be zero so there is only + /// one valid v1 header representation. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() < EVENT_FILE_HEADER_SIZE { + return Err(EventCodecError::invalid(None)); + } + if bytes[..16] != EVENT_FILE_MAGIC + || bytes[16] != EVENTS_VERSION + || bytes[17] != 0 + || le_u16(&bytes[18..20]) as usize != EVENT_FILE_HEADER_SIZE + || le_u16(&bytes[20..22]) as usize != EVENT_RECORD_SIZE + || bytes[22..EVENT_FILE_HEADER_SIZE] + .iter() + .any(|byte| *byte != 0) + { + return Err(EventCodecError::invalid(None)); + } + Ok(Self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EventRequest<'a> { + ConfigGet { + request_id: u32, + }, + ConfigSet { + request_id: u32, + config: EventConfig, + }, + ConfigSetIf { + request_id: u32, + expected: EventConfig, + config: EventConfig, + }, + Dump { + request_id: u32, + from_sequence: u64, + limit: u32, + }, + StreamStart { + request_id: u32, + stream_id: u32, + from_sequence: u64, + flags: u8, + }, + StreamStop { + request_id: u32, + stream_id: u32, + }, + FileStart { + request_id: u32, + stream_id: u32, + flags: u8, + path: &'a str, + }, + FileStop { + request_id: u32, + stream_id: u32, + }, +} + +impl EventRequest<'_> { + pub fn request_id(&self) -> u32 { + match self { + Self::ConfigGet { request_id } + | Self::ConfigSet { request_id, .. } + | Self::ConfigSetIf { request_id, .. } + | Self::Dump { request_id, .. } + | Self::StreamStart { request_id, .. } + | Self::StreamStop { request_id, .. } + | Self::FileStart { request_id, .. } + | Self::FileStop { request_id, .. } => *request_id, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EventMessage { + Status { + request_id: u32, + request_kind: u8, + status: u8, + }, + Config { + request_id: u32, + status: u8, + config: EventConfig, + }, + Dump { + request_id: u32, + status: u8, + first_sequence: u64, + next_sequence: u64, + records: Vec, + }, + StreamStatus { + request_id: u32, + status: u8, + stream_id: u32, + next_sequence: u64, + }, + StreamData { + stream_id: u32, + server_monotonic_ns: u64, + records: Vec, + }, + FileStatus { + request_id: u32, + status: u8, + stream_id: u32, + records_written: u64, + bytes_written: u64, + detail: String, + }, +} + +/// A bounded decode failure. Once the eight-byte envelope is present, +/// `request_id` is retained so dispatch can always send a correlated status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventCodecError { + pub kind: EventCodecErrorKind, + pub request_id: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventCodecErrorKind { + NotEvents, + Truncated, + UnsupportedVersion, + UnknownKind, + InvalidFlags, + Invalid, + TooLarge, + InvalidUtf8, +} + +impl EventCodecError { + const fn new(kind: EventCodecErrorKind, request_id: Option) -> Self { + Self { kind, request_id } + } + + const fn invalid(request_id: Option) -> Self { + Self::new(EventCodecErrorKind::Invalid, request_id) + } + + pub fn status(self) -> u8 { + match self.kind { + EventCodecErrorKind::TooLarge => crate::STATUS_TOO_LARGE, + _ => crate::STATUS_INVALID, + } + } + + /// Build the correlated generic reply available for every malformed request + /// whose envelope was complete. + pub fn status_reply(self, request_kind: u8) -> Option> { + Some(msg_event_status( + self.request_id?, + request_kind, + self.status(), + )) + } +} + +impl fmt::Display for EventCodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self.kind { + EventCodecErrorKind::NotEvents => "not an events packet", + EventCodecErrorKind::Truncated => "events packet is truncated", + EventCodecErrorKind::UnsupportedVersion => "unsupported events version", + EventCodecErrorKind::UnknownKind => "unknown events kind", + EventCodecErrorKind::InvalidFlags => "invalid events flags", + EventCodecErrorKind::Invalid => "invalid events packet", + EventCodecErrorKind::TooLarge => "events field exceeds its limit", + EventCodecErrorKind::InvalidUtf8 => "events text is not valid UTF-8", + }) + } +} + +pub fn events_header(packet: &[u8]) -> Result<(u8, u32, &[u8]), EventCodecError> { + if packet.first() != Some(&EVENTS) { + return Err(EventCodecError::new(EventCodecErrorKind::NotEvents, None)); + } + if packet.len() < EVENTS_HEADER_SIZE { + return Err(EventCodecError::new(EventCodecErrorKind::Truncated, None)); + } + let request_id = le_u32(&packet[4..8]); + if packet[1] != EVENTS_VERSION { + return Err(EventCodecError::new( + EventCodecErrorKind::UnsupportedVersion, + Some(request_id), + )); + } + if packet[3] != 0 { + return Err(EventCodecError::new( + EventCodecErrorKind::InvalidFlags, + Some(request_id), + )); + } + Ok((packet[2], request_id, &packet[8..])) +} + +pub fn parse_event_request(packet: &[u8]) -> Result, EventCodecError> { + let (kind, request_id, body) = events_header(packet)?; + let invalid = || EventCodecError::invalid(Some(request_id)); + match kind { + C2S_CONFIG_GET if body.is_empty() => Ok(EventRequest::ConfigGet { request_id }), + C2S_CONFIG_GET => Err(invalid()), + C2S_CONFIG_SET => { + exact(body, 20, request_id)?; + let config = parse_config(body, request_id)?; + Ok(EventRequest::ConfigSet { request_id, config }) + } + C2S_CONFIG_SET_IF => { + exact(body, 40, request_id)?; + Ok(EventRequest::ConfigSetIf { + request_id, + expected: parse_config(&body[..20], request_id)?, + config: parse_config(&body[20..], request_id)?, + }) + } + C2S_DUMP => { + exact(body, 12, request_id)?; + let limit = le_u32(&body[8..12]); + validate_limit(limit, request_id)?; + Ok(EventRequest::Dump { + request_id, + from_sequence: le_u64(&body[..8]), + limit, + }) + } + C2S_STREAM_START => { + exact(body, 13, request_id)?; + let flags = body[12]; + if flags & !STREAM_FLAGS != 0 { + return Err(EventCodecError::new( + EventCodecErrorKind::InvalidFlags, + Some(request_id), + )); + } + Ok(EventRequest::StreamStart { + request_id, + stream_id: le_u32(&body[..4]), + from_sequence: le_u64(&body[4..12]), + flags, + }) + } + C2S_STREAM_STOP | C2S_FILE_STOP => { + exact(body, 4, request_id)?; + let stream_id = le_u32(body); + if kind == C2S_STREAM_STOP { + Ok(EventRequest::StreamStop { + request_id, + stream_id, + }) + } else { + Ok(EventRequest::FileStop { + request_id, + stream_id, + }) + } + } + C2S_FILE_START => { + if body.len() < 7 { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )); + } + let stream_id = le_u32(&body[..4]); + let flags = body[4]; + if flags & !FILE_FLAGS != 0 { + return Err(EventCodecError::new( + EventCodecErrorKind::InvalidFlags, + Some(request_id), + )); + } + let path_len = le_u16(&body[5..7]) as usize; + if path_len == 0 { + return Err(invalid()); + } + if path_len > EVENTS_PATH_MAX { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + if body.len() < 7 + path_len { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )); + } + if body.len() != 7 + path_len { + return Err(invalid()); + } + let path = std::str::from_utf8(&body[7..]).map_err(|_| { + EventCodecError::new(EventCodecErrorKind::InvalidUtf8, Some(request_id)) + })?; + if path.as_bytes().contains(&0) { + return Err(invalid()); + } + Ok(EventRequest::FileStart { + request_id, + stream_id, + flags, + path, + }) + } + _ => Err(EventCodecError::new( + EventCodecErrorKind::UnknownKind, + Some(request_id), + )), + } +} + +pub fn parse_event_message(packet: &[u8]) -> Result { + let (kind, request_id, body) = events_header(packet)?; + match kind { + S2C_STATUS => { + exact(body, 2, request_id)?; + Ok(EventMessage::Status { + request_id, + request_kind: body[0], + status: body[1], + }) + } + S2C_CONFIG => { + exact(body, 21, request_id)?; + let config = EventConfig::new( + le_u32(&body[1..5]), + Activation(body[5..21].try_into().expect("checked length")), + ) + .map_err(|mut error| { + error.request_id = Some(request_id); + error + })?; + Ok(EventMessage::Config { + request_id, + status: body[0], + config, + }) + } + S2C_DUMP => { + if body.len() < 21 { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )); + } + let count = le_u32(&body[17..21]); + if count > EVENTS_DUMP_MAX_RECORDS { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + let records = decode_records(&body[21..], count as usize, request_id)?; + Ok(EventMessage::Dump { + request_id, + status: body[0], + first_sequence: le_u64(&body[1..9]), + next_sequence: le_u64(&body[9..17]), + records, + }) + } + S2C_STREAM_STATUS => { + exact(body, 13, request_id)?; + Ok(EventMessage::StreamStatus { + request_id, + status: body[0], + stream_id: le_u32(&body[1..5]), + next_sequence: le_u64(&body[5..13]), + }) + } + S2C_STREAM_DATA => { + if request_id != 0 { + return Err(EventCodecError::invalid(Some(request_id))); + } + if body.len() < 16 { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(0), + )); + } + let count = le_u32(&body[12..16]) as usize; + if count > EVENTS_STREAM_MAX_RECORDS { + return Err(EventCodecError::new(EventCodecErrorKind::TooLarge, Some(0))); + } + Ok(EventMessage::StreamData { + stream_id: le_u32(&body[..4]), + server_monotonic_ns: le_u64(&body[4..12]), + records: decode_records(&body[16..], count, 0)?, + }) + } + S2C_FILE_STATUS => { + if body.len() < 23 { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )); + } + let detail_len = le_u16(&body[21..23]) as usize; + if detail_len > EVENTS_DETAIL_MAX { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + if body.len() < 23 + detail_len { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )); + } + if body.len() != 23 + detail_len { + return Err(EventCodecError::invalid(Some(request_id))); + } + let detail = std::str::from_utf8(&body[23..]) + .map_err(|_| { + EventCodecError::new(EventCodecErrorKind::InvalidUtf8, Some(request_id)) + })? + .to_owned(); + Ok(EventMessage::FileStatus { + request_id, + status: body[0], + stream_id: le_u32(&body[1..5]), + records_written: le_u64(&body[5..13]), + bytes_written: le_u64(&body[13..21]), + detail, + }) + } + _ => Err(EventCodecError::new( + EventCodecErrorKind::UnknownKind, + Some(request_id), + )), + } +} + +pub fn msg_config_get(request_id: u32) -> Vec { + envelope(C2S_CONFIG_GET, request_id, 0) +} + +pub fn msg_config_set(request_id: u32, config: EventConfig) -> Result, EventCodecError> { + validate_ring_size(config.ring_size)?; + let mut msg = envelope(C2S_CONFIG_SET, request_id, 20); + push_config(&mut msg, config); + Ok(msg) +} + +pub fn msg_config_set_if( + request_id: u32, + expected: EventConfig, + config: EventConfig, +) -> Result, EventCodecError> { + validate_ring_size(expected.ring_size)?; + validate_ring_size(config.ring_size)?; + let mut msg = envelope(C2S_CONFIG_SET_IF, request_id, 40); + push_config(&mut msg, expected); + push_config(&mut msg, config); + Ok(msg) +} + +pub fn msg_dump( + request_id: u32, + from_sequence: u64, + limit: u32, +) -> Result, EventCodecError> { + validate_limit(limit, request_id)?; + let mut msg = envelope(C2S_DUMP, request_id, 12); + msg.extend_from_slice(&from_sequence.to_le_bytes()); + msg.extend_from_slice(&limit.to_le_bytes()); + Ok(msg) +} + +pub fn msg_stream_start( + request_id: u32, + stream_id: u32, + from_sequence: u64, + flags: u8, +) -> Result, EventCodecError> { + if flags & !STREAM_FLAGS != 0 { + return Err(EventCodecError::new( + EventCodecErrorKind::InvalidFlags, + Some(request_id), + )); + } + let mut msg = envelope(C2S_STREAM_START, request_id, 13); + msg.extend_from_slice(&stream_id.to_le_bytes()); + msg.extend_from_slice(&from_sequence.to_le_bytes()); + msg.push(flags); + Ok(msg) +} + +pub fn msg_stream_stop(request_id: u32, stream_id: u32) -> Vec { + id_request(C2S_STREAM_STOP, request_id, stream_id) +} + +pub fn msg_file_start( + request_id: u32, + stream_id: u32, + flags: u8, + path: &str, +) -> Result, EventCodecError> { + if flags & !FILE_FLAGS != 0 { + return Err(EventCodecError::new( + EventCodecErrorKind::InvalidFlags, + Some(request_id), + )); + } + if path.is_empty() || path.as_bytes().contains(&0) { + return Err(EventCodecError::invalid(Some(request_id))); + } + if path.len() > EVENTS_PATH_MAX { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + let mut msg = envelope(C2S_FILE_START, request_id, 7 + path.len()); + msg.extend_from_slice(&stream_id.to_le_bytes()); + msg.push(flags); + msg.extend_from_slice(&(path.len() as u16).to_le_bytes()); + msg.extend_from_slice(path.as_bytes()); + Ok(msg) +} + +pub fn msg_file_stop(request_id: u32, stream_id: u32) -> Vec { + id_request(C2S_FILE_STOP, request_id, stream_id) +} + +pub fn msg_event_status(request_id: u32, request_kind: u8, status: u8) -> Vec { + let mut msg = envelope(S2C_STATUS, request_id, 2); + msg.extend_from_slice(&[request_kind, status]); + msg +} + +pub fn msg_event_config( + request_id: u32, + status: u8, + config: EventConfig, +) -> Result, EventCodecError> { + validate_ring_size(config.ring_size)?; + let mut msg = envelope(S2C_CONFIG, request_id, 21); + msg.push(status); + msg.extend_from_slice(&config.ring_size.to_le_bytes()); + msg.extend_from_slice(&config.activation.0); + Ok(msg) +} + +pub fn msg_event_dump( + request_id: u32, + status: u8, + first_sequence: u64, + next_sequence: u64, + records: &[EventRecord], +) -> Result, EventCodecError> { + if records.len() > EVENTS_DUMP_MAX_RECORDS as usize { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + let mut msg = envelope(S2C_DUMP, request_id, 21 + records.len() * EVENT_RECORD_SIZE); + msg.push(status); + msg.extend_from_slice(&first_sequence.to_le_bytes()); + msg.extend_from_slice(&next_sequence.to_le_bytes()); + msg.extend_from_slice(&(records.len() as u32).to_le_bytes()); + push_records(&mut msg, records); + Ok(msg) +} + +pub fn msg_event_stream_status( + request_id: u32, + status: u8, + stream_id: u32, + next_sequence: u64, +) -> Vec { + let mut msg = envelope(S2C_STREAM_STATUS, request_id, 13); + msg.push(status); + msg.extend_from_slice(&stream_id.to_le_bytes()); + msg.extend_from_slice(&next_sequence.to_le_bytes()); + msg +} + +pub fn msg_event_stream_data( + stream_id: u32, + server_monotonic_ns: u64, + records: &[EventRecord], +) -> Result, EventCodecError> { + if records.len() > EVENTS_STREAM_MAX_RECORDS { + return Err(EventCodecError::new(EventCodecErrorKind::TooLarge, None)); + } + let mut msg = envelope(S2C_STREAM_DATA, 0, 16 + records.len() * EVENT_RECORD_SIZE); + msg.extend_from_slice(&stream_id.to_le_bytes()); + msg.extend_from_slice(&server_monotonic_ns.to_le_bytes()); + msg.extend_from_slice(&(records.len() as u32).to_le_bytes()); + push_records(&mut msg, records); + Ok(msg) +} + +pub fn msg_event_file_status( + request_id: u32, + status: u8, + stream_id: u32, + records_written: u64, + bytes_written: u64, + detail: &str, +) -> Result, EventCodecError> { + if detail.len() > EVENTS_DETAIL_MAX { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + let mut msg = envelope(S2C_FILE_STATUS, request_id, 23 + detail.len()); + msg.push(status); + msg.extend_from_slice(&stream_id.to_le_bytes()); + msg.extend_from_slice(&records_written.to_le_bytes()); + msg.extend_from_slice(&bytes_written.to_le_bytes()); + msg.extend_from_slice(&(detail.len() as u16).to_le_bytes()); + msg.extend_from_slice(detail.as_bytes()); + Ok(msg) +} + +fn envelope(kind: u8, request_id: u32, body_len: usize) -> Vec { + let mut msg = Vec::with_capacity(EVENTS_HEADER_SIZE + body_len); + msg.extend_from_slice(&[EVENTS, EVENTS_VERSION, kind, 0]); + msg.extend_from_slice(&request_id.to_le_bytes()); + msg +} + +fn id_request(kind: u8, request_id: u32, stream_id: u32) -> Vec { + let mut msg = envelope(kind, request_id, 4); + msg.extend_from_slice(&stream_id.to_le_bytes()); + msg +} + +fn parse_config(body: &[u8], request_id: u32) -> Result { + EventConfig::new( + le_u32(&body[..4]), + Activation(body[4..20].try_into().expect("checked config length")), + ) + .map_err(|mut error| { + error.request_id = Some(request_id); + error + }) +} + +fn validate_ring_size(ring_size: u32) -> Result<(), EventCodecError> { + if !(EVENTS_RING_MIN..=EVENTS_RING_MAX).contains(&ring_size) { + return Err(EventCodecError::new(EventCodecErrorKind::TooLarge, None)); + } + Ok(()) +} + +fn validate_limit(limit: u32, request_id: u32) -> Result<(), EventCodecError> { + if limit == 0 || limit > EVENTS_DUMP_MAX_RECORDS { + return Err(EventCodecError::new( + EventCodecErrorKind::TooLarge, + Some(request_id), + )); + } + Ok(()) +} + +fn exact(body: &[u8], len: usize, request_id: u32) -> Result<(), EventCodecError> { + if body.len() < len { + Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )) + } else if body.len() > len { + Err(EventCodecError::invalid(Some(request_id))) + } else { + Ok(()) + } +} + +fn decode_records( + bytes: &[u8], + count: usize, + request_id: u32, +) -> Result, EventCodecError> { + let expected = count + .checked_mul(EVENT_RECORD_SIZE) + .ok_or_else(|| EventCodecError::new(EventCodecErrorKind::TooLarge, Some(request_id)))?; + if bytes.len() < expected { + return Err(EventCodecError::new( + EventCodecErrorKind::Truncated, + Some(request_id), + )); + } + if bytes.len() > expected { + return Err(EventCodecError::invalid(Some(request_id))); + } + bytes + .chunks_exact(EVENT_RECORD_SIZE) + .map(EventRecord::decode) + .collect() +} + +fn push_config(out: &mut Vec, config: EventConfig) { + out.extend_from_slice(&config.ring_size.to_le_bytes()); + out.extend_from_slice(&config.activation.0); +} + +fn push_records(out: &mut Vec, records: &[EventRecord]) { + for record in records { + out.extend_from_slice(&record.encode()); + } +} + +fn le_u16(bytes: &[u8]) -> u16 { + u16::from_le_bytes(bytes.try_into().expect("fixed field")) +} + +fn le_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes.try_into().expect("fixed field")) +} + +fn le_u64(bytes: &[u8]) -> u64 { + u64::from_le_bytes(bytes.try_into().expect("fixed field")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> EventRecord { + EventRecord { + sequence: 0x0102_0304_0506_0708, + monotonic_ns: 0x1112_1314_1516_1718, + event_id: 0x2122_2324, + flags: 0x3132, + source: 0x41, + schema: 0x42, + connection: 0x5152_5354_5556_5758, + subject: 0x6162_6364_6566_6768, + args: [ + 0x7172_7374_7576_7778, + 0x8182_8384_8586_8788, + 0x9192_9394_9596_9798, + ], + } + } + + fn config() -> EventConfig { + EventConfig::new(4096, Activation([0x5a; 16])).unwrap() + } + + #[test] + fn allocations_are_locked() { + assert_eq!(EVENTS, 0x96); + assert_eq!(FEATURE_EVENTS, 1 << 31); + assert_eq!(EVENTS_VERSION, 1); + } + + #[test] + fn event_record_has_fixed_size_and_golden_bytes() { + assert_eq!(std::mem::size_of::(), EVENT_RECORD_SIZE); + let bytes = record().encode(); + assert_eq!(bytes.len(), 64); + assert_eq!( + bytes, + [ + 8, 7, 6, 5, 4, 3, 2, 1, 24, 23, 22, 21, 20, 19, 18, 17, 36, 35, 34, 33, 50, 49, 65, + 66, 88, 87, 86, 85, 84, 83, 82, 81, 104, 103, 102, 101, 100, 99, 98, 97, 120, 119, + 118, 117, 116, 115, 114, 113, 136, 135, 134, 133, 132, 131, 130, 129, 152, 151, + 150, 149, 148, 147, 146, 145, + ] + ); + assert_eq!(EventRecord::decode(&bytes), Ok(record())); + assert!(EventRecord::decode(&bytes[..63]).is_err()); + assert!(EventRecord::decode(&[0; 65]).is_err()); + } + + #[test] + fn canonical_file_header_is_golden_and_strict() { + let header = EventFileHeader::CANONICAL.encode(); + assert_eq!(header.len(), EVENT_FILE_HEADER_SIZE); + assert_eq!( + header, + [ + b'b', b'l', b'i', b't', b'.', b'e', b'v', b'e', b'n', b't', b's', b'.', b'v', b'1', + 0, 0, 1, 0, 32, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ] + ); + assert_eq!(EventFileHeader::decode(&header), Ok(EventFileHeader)); + let mut file = header.to_vec(); + file.extend_from_slice(&record().encode()); + assert_eq!(EventFileHeader::decode(&file), Ok(EventFileHeader)); + for cut in 0..EVENT_FILE_HEADER_SIZE { + assert!(EventFileHeader::decode(&header[..cut]).is_err()); + } + for index in [0, 16, 17, 18, 20, 31] { + let mut bad = header; + bad[index] ^= 1; + assert!(EventFileHeader::decode(&bad).is_err(), "byte {index}"); + } + } + + #[test] + fn activation_is_a_128_bit_set() { + let mut activation = Activation::NONE; + for id in [0, 7, 8, 127] { + activation.set(id, true); + assert!(activation.contains(id)); + } + activation.set(8, false); + assert!(!activation.contains(8)); + assert!(activation.contains(127)); + } + + #[test] + fn request_golden_bytes_and_round_trips() { + assert_eq!( + msg_config_get(0x1234_5678), + vec![0x96, 1, 1, 0, 0x78, 0x56, 0x34, 0x12] + ); + let set = msg_config_set(7, config()).unwrap(); + assert_eq!( + parse_event_request(&set), + Ok(EventRequest::ConfigSet { + request_id: 7, + config: config() + }) + ); + let conditional = msg_config_set_if( + 8, + config(), + EventConfig::new(8192, Activation::ALL).unwrap(), + ) + .unwrap(); + assert_eq!( + parse_event_request(&conditional), + Ok(EventRequest::ConfigSetIf { + request_id: 8, + expected: config(), + config: EventConfig::new(8192, Activation::ALL).unwrap(), + }) + ); + let dump = msg_dump(8, 99, 12).unwrap(); + assert_eq!( + parse_event_request(&dump), + Ok(EventRequest::Dump { + request_id: 8, + from_sequence: 99, + limit: 12 + }) + ); + let start = msg_stream_start(9, 22, 100, STREAM_FOLLOW).unwrap(); + assert_eq!( + parse_event_request(&start), + Ok(EventRequest::StreamStart { + request_id: 9, + stream_id: 22, + from_sequence: 100, + flags: STREAM_FOLLOW + }) + ); + assert_eq!( + parse_event_request(&msg_stream_stop(10, 22)), + Ok(EventRequest::StreamStop { + request_id: 10, + stream_id: 22 + }) + ); + let file = msg_file_start(11, 23, FILE_APPEND, "/tmp/blit.events").unwrap(); + assert_eq!( + parse_event_request(&file), + Ok(EventRequest::FileStart { + request_id: 11, + stream_id: 23, + flags: FILE_APPEND, + path: "/tmp/blit.events" + }) + ); + assert_eq!( + parse_event_request(&msg_file_stop(12, 23)), + Ok(EventRequest::FileStop { + request_id: 12, + stream_id: 23 + }) + ); + } + + #[test] + fn replies_round_trip() { + let cases = [ + msg_event_status(1, C2S_CONFIG_SET, crate::STATUS_INVALID), + msg_event_config(2, crate::STATUS_OK, config()).unwrap(), + msg_event_dump(3, crate::STATUS_OK, 4, 6, &[record()]).unwrap(), + msg_event_stream_status(4, crate::STATUS_OK, 5, 6), + msg_event_stream_data(5, 99, &[record(), record()]).unwrap(), + msg_event_file_status(6, crate::STATUS_OTHER, 7, 8, 512, "disk full").unwrap(), + ]; + assert!(matches!( + parse_event_message(&cases[0]), + Ok(EventMessage::Status { .. }) + )); + assert!(matches!( + parse_event_message(&cases[1]), + Ok(EventMessage::Config { .. }) + )); + assert!( + matches!(parse_event_message(&cases[2]), Ok(EventMessage::Dump { records, .. }) if records == vec![record()]) + ); + assert!(matches!( + parse_event_message(&cases[3]), + Ok(EventMessage::StreamStatus { .. }) + )); + assert!( + matches!(parse_event_message(&cases[4]), Ok(EventMessage::StreamData { server_monotonic_ns: 99, records, .. }) if records.len() == 2) + ); + assert!( + matches!(parse_event_message(&cases[5]), Ok(EventMessage::FileStatus { detail, .. }) if detail == "disk full") + ); + } + + #[test] + fn every_truncated_known_packet_is_rejected() { + let requests = [ + msg_config_set(7, config()).unwrap(), + msg_config_set_if( + 7, + config(), + EventConfig::new(8192, Activation::ALL).unwrap(), + ) + .unwrap(), + msg_dump(7, 1, 1).unwrap(), + msg_stream_start(7, 1, 1, 0).unwrap(), + msg_stream_stop(7, 1), + msg_file_start(7, 1, 0, "/tmp/x").unwrap(), + msg_file_stop(7, 1), + ]; + for packet in requests { + for cut in 0..packet.len() { + assert!( + parse_event_request(&packet[..cut]).is_err(), + "request cut {cut}" + ); + } + } + let replies = [ + msg_event_config(7, 0, config()).unwrap(), + msg_event_dump(7, 0, 1, 2, &[record()]).unwrap(), + msg_event_stream_status(7, 0, 1, 2), + msg_event_stream_data(1, 99, &[record()]).unwrap(), + msg_event_file_status(7, 0, 1, 2, 128, "ok").unwrap(), + ]; + for packet in replies { + for cut in 0..packet.len() { + assert!( + parse_event_message(&packet[..cut]).is_err(), + "reply cut {cut}" + ); + } + } + } + + #[test] + fn unknown_versions_kinds_and_flags_are_rejected_with_correlation() { + for (index, value, kind) in [ + (1, 2, EventCodecErrorKind::UnsupportedVersion), + (2, 0xff, EventCodecErrorKind::UnknownKind), + (3, 1, EventCodecErrorKind::InvalidFlags), + ] { + let mut packet = msg_config_get(0x4433_2211); + packet[index] = value; + let error = parse_event_request(&packet).unwrap_err(); + assert_eq!(error.kind, kind); + assert_eq!(error.request_id, Some(0x4433_2211)); + let reply = error.status_reply(packet[2]).unwrap(); + assert!(matches!( + parse_event_message(&reply), + Ok(EventMessage::Status { + request_id: 0x4433_2211, + status: crate::STATUS_INVALID, + .. + }) + )); + } + } + + #[test] + fn operation_flags_and_trailing_bytes_are_rejected() { + assert!(matches!( + msg_stream_start(1, 1, 1, 0x80).unwrap_err().kind, + EventCodecErrorKind::InvalidFlags + )); + assert!(matches!( + msg_file_start(1, 1, 0x80, "x").unwrap_err().kind, + EventCodecErrorKind::InvalidFlags + )); + let mut packet = msg_config_get(1); + packet.push(0); + assert!(parse_event_request(&packet).is_err()); + let mut stream = msg_stream_start(1, 1, 1, 0).unwrap(); + stream[20] = 0x80; + assert_eq!( + parse_event_request(&stream).unwrap_err().kind, + EventCodecErrorKind::InvalidFlags + ); + } + + #[test] + fn oversized_paths_and_counts_are_bounded() { + let path = "x".repeat(EVENTS_PATH_MAX + 1); + assert_eq!( + msg_file_start(9, 1, 0, &path).unwrap_err().kind, + EventCodecErrorKind::TooLarge + ); + + let mut packet = envelope(C2S_FILE_START, 9, 7); + packet.extend_from_slice(&1u32.to_le_bytes()); + packet.push(0); + packet.extend_from_slice(&((EVENTS_PATH_MAX + 1) as u16).to_le_bytes()); + assert_eq!( + parse_event_request(&packet).unwrap_err().kind, + EventCodecErrorKind::TooLarge + ); + assert_eq!( + msg_dump(9, 0, EVENTS_DUMP_MAX_RECORDS + 1) + .unwrap_err() + .kind, + EventCodecErrorKind::TooLarge + ); + assert!(EventConfig::new(EVENTS_RING_MAX + 1, Activation::ALL).is_err()); + } + + #[test] + fn record_count_cannot_claim_past_packet() { + let mut dump = msg_event_dump(4, 0, 1, 2, &[record()]).unwrap(); + dump[25..29].copy_from_slice(&2u32.to_le_bytes()); + assert_eq!( + parse_event_message(&dump).unwrap_err().kind, + EventCodecErrorKind::Truncated + ); + + let mut stream = msg_event_stream_data(3, 99, &[record()]).unwrap(); + stream[20..24].copy_from_slice(&(EVENTS_STREAM_MAX_RECORDS as u32 + 1).to_le_bytes()); + assert_eq!( + parse_event_message(&stream).unwrap_err().kind, + EventCodecErrorKind::TooLarge + ); + } + + #[test] + fn paths_are_utf8_nonempty_and_nul_free() { + assert!(msg_file_start(1, 1, 0, "").is_err()); + assert!(msg_file_start(1, 1, 0, "a\0b").is_err()); + let mut packet = msg_file_start(1, 1, 0, "x").unwrap(); + *packet.last_mut().unwrap() = 0xff; + assert_eq!( + parse_event_request(&packet).unwrap_err().kind, + EventCodecErrorKind::InvalidUtf8 + ); + } +} diff --git a/crates/remote/src/lib.rs b/crates/remote/src/lib.rs index 984142b9..3fa18c5a 100644 --- a/crates/remote/src/lib.rs +++ b/crates/remote/src/lib.rs @@ -44,6 +44,10 @@ pub mod media; /// cursor a client feeds back to read only what is new. pub mod journal; +/// Structured server events (`blit.events.v1`): configuration, ring dumps, +/// live streams, server-side file streams, and the fixed binary record codec. +pub mod events; + /// Cap on any single LZ4-decompressed payload, protocol-wide /// (docs/protocol.md "Compressed payloads"). Receivers check the prepended /// size against it *before* allocating, so a hostile or corrupt length diff --git a/crates/server/src/events.rs b/crates/server/src/events.rs new file mode 100644 index 00000000..c10cf068 --- /dev/null +++ b/crates/server/src/events.rs @@ -0,0 +1,2532 @@ +//! Server-owned structured event recording. + +#![allow(dead_code, unused_imports, unused_macros)] + +use blit_remote::events::{ + Activation, C2S_CONFIG_GET, C2S_CONFIG_SET, C2S_DUMP, C2S_FILE_START, C2S_FILE_STOP, + C2S_STREAM_START, C2S_STREAM_STOP, EVENT_FILE_HEADER_SIZE, EVENT_RECORD_SIZE, EVENTS_RING_MAX, + EVENTS_RING_MIN, EventConfig, EventFileHeader, EventRecord, EventRequest, FILE_APPEND, + FILE_SYNC, STREAM_FLAGS, STREAM_FOLLOW, msg_event_config, msg_event_dump, + msg_event_file_status, msg_event_status, msg_event_stream_data, msg_event_stream_status, + parse_event_request, +}; +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::time::Instant; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot, watch}; +use tokio::task::JoinHandle; + +pub(crate) const DEFAULT_RING_BYTES: u64 = 1024 * 1024; +pub(crate) const DEFAULT_RING_RECORDS: u32 = (DEFAULT_RING_BYTES / EVENT_RECORD_SIZE as u64) as u32; +pub(crate) const MAX_FILE_STREAMS: usize = 4; + +/// Stable event ids. Values are activation-bit positions and must remain below 128. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum EventId { + ServerStarting = 0, + ServerStarted = 1, + ServerStopping = 2, + ServerStopped = 3, + ServerError = 4, + ClientConnected = 8, + ClientReady = 9, + ClientDisconnecting = 10, + ClientDisconnected = 11, + ClientError = 12, + RawRequestRead = 16, + RawRequestDispatch = 17, + RawRequestDone = 18, + RawRequestReject = 19, + WriterDequeue = 24, + WriterWriteBegin = 25, + WriterWriteEnd = 26, + WriterError = 27, + WriterBackpressure = 28, + PtyCreateRequest = 32, + PtyCreateMutexAcquired = 33, + PtyCreateSpawnBegin = 34, + PtyCreateSpawnEnd = 35, + PtyCreateRegistered = 36, + PtyCreateReplyQueued = 37, + PtyCreateError = 38, + PtyRead = 40, + PtyQueue = 41, + PtyDrain = 42, + PtyParse = 43, + PtyFrameQueued = 44, + PtyInput = 45, + PtyResize = 46, + PtyExit = 47, + PtyEvict = 48, + PtyIoError = 49, + ProcessRequest = 56, + ProcessSpawn = 57, + ProcessResult = 58, + ProcessIo = 59, + ProcessExit = 60, + ProcessError = 61, + CompositorStarted = 64, + CompositorStopped = 65, + CompositorError = 66, + SurfaceCreated = 68, + SurfaceDestroyed = 69, + SurfaceFrameQueued = 70, + SurfaceError = 71, + ProtocolCore = 72, + ProtocolPty = 73, + ProtocolProcess = 74, + ProtocolCompositor = 75, + ProtocolSurface = 76, + ProtocolInput = 77, + ProtocolClipboard = 78, + ProtocolFilesystem = 79, + ProtocolNetwork = 80, + ProtocolKv = 81, + ProtocolBrowser = 82, + ProtocolAudio = 83, + ProtocolEvents = 84, + ProtocolIntegration = 85, + ProtocolError = 87, + TaskSpawned = 104, + TaskCompleted = 105, + TaskCancelled = 106, + TaskFailed = 107, + ConfigChanged = 112, + ConfigError = 113, + RingDropped = 114, + RingOverwritten = 115, + StreamGap = 116, + StreamError = 117, +} + +impl EventId { + const ALL: &'static [Self] = &[ + Self::ServerStarting, + Self::ServerStarted, + Self::ServerStopping, + Self::ServerStopped, + Self::ServerError, + Self::ClientConnected, + Self::ClientReady, + Self::ClientDisconnecting, + Self::ClientDisconnected, + Self::ClientError, + Self::RawRequestRead, + Self::RawRequestDispatch, + Self::RawRequestDone, + Self::RawRequestReject, + Self::WriterDequeue, + Self::WriterWriteBegin, + Self::WriterWriteEnd, + Self::WriterError, + Self::WriterBackpressure, + Self::PtyCreateRequest, + Self::PtyCreateMutexAcquired, + Self::PtyCreateSpawnBegin, + Self::PtyCreateSpawnEnd, + Self::PtyCreateRegistered, + Self::PtyCreateReplyQueued, + Self::PtyCreateError, + Self::PtyRead, + Self::PtyQueue, + Self::PtyDrain, + Self::PtyParse, + Self::PtyFrameQueued, + Self::PtyInput, + Self::PtyResize, + Self::PtyExit, + Self::PtyEvict, + Self::PtyIoError, + Self::ProcessRequest, + Self::ProcessSpawn, + Self::ProcessResult, + Self::ProcessIo, + Self::ProcessExit, + Self::ProcessError, + Self::CompositorStarted, + Self::CompositorStopped, + Self::CompositorError, + Self::SurfaceCreated, + Self::SurfaceDestroyed, + Self::SurfaceFrameQueued, + Self::SurfaceError, + Self::ProtocolCore, + Self::ProtocolPty, + Self::ProtocolProcess, + Self::ProtocolCompositor, + Self::ProtocolSurface, + Self::ProtocolInput, + Self::ProtocolClipboard, + Self::ProtocolFilesystem, + Self::ProtocolNetwork, + Self::ProtocolKv, + Self::ProtocolBrowser, + Self::ProtocolAudio, + Self::ProtocolEvents, + Self::ProtocolIntegration, + Self::ProtocolError, + Self::TaskSpawned, + Self::TaskCompleted, + Self::TaskCancelled, + Self::TaskFailed, + Self::ConfigChanged, + Self::ConfigError, + Self::RingDropped, + Self::RingOverwritten, + Self::StreamGap, + Self::StreamError, + ]; + + const fn family(self) -> EventFamily { + match self as u8 { + 0..=7 => EventFamily::Server, + 8..=15 => EventFamily::Client, + 16..=23 => EventFamily::Request, + 24..=31 => EventFamily::Writer, + 32..=55 => EventFamily::Pty, + 56..=63 => EventFamily::Process, + 64..=67 => EventFamily::Compositor, + 68..=71 => EventFamily::Surface, + 72..=103 => EventFamily::Protocol, + 104..=111 => EventFamily::Task, + _ => EventFamily::Recorder, + } + } + + const fn name(self) -> &'static str { + match self { + Self::ServerStarting => "server-starting", + Self::ServerStarted => "server-started", + Self::ServerStopping => "server-stopping", + Self::ServerStopped => "server-stopped", + Self::ServerError => "server-error", + Self::ClientConnected => "client-connected", + Self::ClientReady => "client-ready", + Self::ClientDisconnecting => "client-disconnecting", + Self::ClientDisconnected => "client-disconnected", + Self::ClientError => "client-error", + Self::RawRequestRead => "raw-request-read", + Self::RawRequestDispatch => "raw-request-dispatch", + Self::RawRequestDone => "raw-request-done", + Self::RawRequestReject => "raw-request-reject", + Self::WriterDequeue => "writer-dequeue", + Self::WriterWriteBegin => "writer-write-begin", + Self::WriterWriteEnd => "writer-write-end", + Self::WriterError => "writer-error", + Self::WriterBackpressure => "writer-backpressure", + Self::PtyCreateRequest => "pty-create-request", + Self::PtyCreateMutexAcquired => "pty-create-mutex-acquired", + Self::PtyCreateSpawnBegin => "pty-create-spawn-begin", + Self::PtyCreateSpawnEnd => "pty-create-spawn-end", + Self::PtyCreateRegistered => "pty-create-registered", + Self::PtyCreateReplyQueued => "pty-create-reply-queued", + Self::PtyCreateError => "pty-create-error", + Self::PtyRead => "pty-read", + Self::PtyQueue => "pty-queue", + Self::PtyDrain => "pty-drain", + Self::PtyParse => "pty-parse", + Self::PtyFrameQueued => "pty-frame-queued", + Self::PtyInput => "pty-input", + Self::PtyResize => "pty-resize", + Self::PtyExit => "pty-exit", + Self::PtyEvict => "pty-evict", + Self::PtyIoError => "pty-io-error", + Self::ProcessRequest => "process-request", + Self::ProcessSpawn => "process-spawn", + Self::ProcessResult => "process-result", + Self::ProcessIo => "process-io", + Self::ProcessExit => "process-exit", + Self::ProcessError => "process-error", + Self::CompositorStarted => "compositor-started", + Self::CompositorStopped => "compositor-stopped", + Self::CompositorError => "compositor-error", + Self::SurfaceCreated => "surface-created", + Self::SurfaceDestroyed => "surface-destroyed", + Self::SurfaceFrameQueued => "surface-frame-queued", + Self::SurfaceError => "surface-error", + Self::ProtocolCore => "protocol-core", + Self::ProtocolPty => "protocol-pty", + Self::ProtocolProcess => "protocol-process", + Self::ProtocolCompositor => "protocol-compositor", + Self::ProtocolSurface => "protocol-surface", + Self::ProtocolInput => "protocol-input", + Self::ProtocolClipboard => "protocol-clipboard", + Self::ProtocolFilesystem => "protocol-filesystem", + Self::ProtocolNetwork => "protocol-network", + Self::ProtocolKv => "protocol-kv", + Self::ProtocolBrowser => "protocol-browser", + Self::ProtocolAudio => "protocol-audio", + Self::ProtocolEvents => "protocol-events", + Self::ProtocolIntegration => "protocol-integration", + Self::ProtocolError => "protocol-error", + Self::TaskSpawned => "task-spawned", + Self::TaskCompleted => "task-completed", + Self::TaskCancelled => "task-cancelled", + Self::TaskFailed => "task-failed", + Self::ConfigChanged => "config-changed", + Self::ConfigError => "config-error", + Self::RingDropped => "ring-dropped", + Self::RingOverwritten => "ring-overwritten", + Self::StreamGap => "stream-gap", + Self::StreamError => "stream-error", + } + } + + fn named(name: &str) -> Option { + let normalized = name.replace('_', "-").to_ascii_lowercase(); + Self::ALL + .iter() + .copied() + .find(|event| event.name() == normalized) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EventFamily { + Server, + Client, + Request, + Writer, + Pty, + Process, + Compositor, + Surface, + Protocol, + Task, + Recorder, +} + +impl EventFamily { + fn named(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "server" | "lifecycle" => Some(Self::Server), + "client" | "clients" => Some(Self::Client), + "request" | "requests" | "raw-request" | "raw-requests" => Some(Self::Request), + "writer" | "writers" => Some(Self::Writer), + "pty" | "pty-create" => Some(Self::Pty), + "process" | "processes" => Some(Self::Process), + "compositor" => Some(Self::Compositor), + "surface" | "surfaces" => Some(Self::Surface), + "protocol" | "protocols" | "integration" | "integrations" => Some(Self::Protocol), + "task" | "tasks" => Some(Self::Task), + "recorder" | "config" | "ring" | "stream" => Some(Self::Recorder), + _ => None, + } + } +} + +fn default_activation() -> Activation { + let mut activation = Activation::NONE; + for event in [ + EventId::ServerStarting, + EventId::ServerStarted, + EventId::ServerStopping, + EventId::ServerStopped, + EventId::ServerError, + EventId::ClientConnected, + EventId::ClientReady, + EventId::ClientDisconnecting, + EventId::ClientDisconnected, + EventId::RawRequestReject, + EventId::WriterError, + EventId::PtyCreateRequest, + EventId::PtyCreateMutexAcquired, + EventId::PtyCreateSpawnBegin, + EventId::PtyCreateSpawnEnd, + EventId::PtyCreateRegistered, + EventId::PtyCreateReplyQueued, + EventId::PtyCreateError, + EventId::PtyExit, + EventId::PtyEvict, + EventId::ProcessRequest, + EventId::ProcessSpawn, + EventId::ProcessResult, + EventId::ProcessExit, + EventId::CompositorStarted, + EventId::CompositorStopped, + EventId::SurfaceCreated, + EventId::SurfaceDestroyed, + EventId::ConfigChanged, + ] { + activation.set(event as u8, true); + } + activation +} + +struct Slot { + writing: AtomicBool, + sequence: AtomicU64, + monotonic_ns: AtomicU64, + metadata: AtomicU64, + connection: AtomicU64, + subject: AtomicU64, + args: [AtomicU64; 3], +} + +impl Slot { + fn empty() -> Self { + Self { + writing: AtomicBool::new(false), + sequence: AtomicU64::new(0), + monotonic_ns: AtomicU64::new(0), + metadata: AtomicU64::new(0), + connection: AtomicU64::new(0), + subject: AtomicU64::new(0), + args: std::array::from_fn(|_| AtomicU64::new(0)), + } + } + + fn write(&self, record: EventRecord) -> bool { + if self + .writing + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return false; + } + self.sequence.store(0, Ordering::Release); + self.monotonic_ns + .store(record.monotonic_ns, Ordering::Relaxed); + self.metadata.store( + record.event_id as u64 + | ((record.flags as u64) << 32) + | ((record.source as u64) << 48) + | ((record.schema as u64) << 56), + Ordering::Relaxed, + ); + self.connection.store(record.connection, Ordering::Relaxed); + self.subject.store(record.subject, Ordering::Relaxed); + for (target, value) in self.args.iter().zip(record.args) { + target.store(value, Ordering::Relaxed); + } + self.sequence.store(record.sequence, Ordering::Release); + self.writing.store(false, Ordering::Release); + true + } + + fn read(&self, expected: u64) -> Option { + if self.writing.load(Ordering::Acquire) { + return None; + } + let sequence = self.sequence.load(Ordering::Acquire); + if sequence != expected { + return None; + } + let metadata = self.metadata.load(Ordering::Relaxed); + let record = EventRecord { + sequence, + monotonic_ns: self.monotonic_ns.load(Ordering::Relaxed), + event_id: metadata as u32, + flags: (metadata >> 32) as u16, + source: (metadata >> 48) as u8, + schema: (metadata >> 56) as u8, + connection: self.connection.load(Ordering::Relaxed), + subject: self.subject.load(Ordering::Relaxed), + args: std::array::from_fn(|index| self.args[index].load(Ordering::Relaxed)), + }; + if self.writing.load(Ordering::Acquire) || self.sequence.load(Ordering::Acquire) != expected + { + None + } else { + Some(record) + } + } +} + +struct Ring { + slots: Box<[Slot]>, +} + +impl Ring { + fn new(size: u32) -> Self { + Self { + slots: (0..size).map(|_| Slot::empty()).collect(), + } + } + + fn len(&self) -> u64 { + self.slots.len() as u64 + } + + fn slot(&self, sequence: u64) -> &Slot { + &self.slots[(sequence % self.len()) as usize] + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SequenceGap { + pub first_sequence: u64, + pub next_sequence: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EventSnapshot { + pub first_sequence: u64, + pub next_sequence: u64, + pub overwritten: u64, + pub gaps: Vec, + pub records: Vec, +} + +pub(crate) struct EventRecorder { + config_lock: Mutex<()>, + activation: [AtomicU64; 2], + ring: RwLock>, + next_sequence: AtomicU64, + dropped: AtomicU64, + started: Instant, + changed: watch::Sender, +} + +impl Default for EventRecorder { + fn default() -> Self { + Self::new(EventConfig { + ring_size: DEFAULT_RING_RECORDS, + activation: default_activation(), + }) + .expect("default event configuration is valid") + } +} + +impl EventRecorder { + pub(crate) fn new(config: EventConfig) -> Result { + validate_config(config)?; + let words = activation_words(config.activation); + Ok(Self { + config_lock: Mutex::new(()), + activation: [AtomicU64::new(words[0]), AtomicU64::new(words[1])], + ring: RwLock::new(Arc::new(Ring::new(config.ring_size))), + next_sequence: AtomicU64::new(1), + dropped: AtomicU64::new(0), + started: Instant::now(), + changed: watch::channel(0).0, + }) + } + + #[inline] + pub(crate) fn enabled(&self, event: EventId) -> bool { + let id = event as u8; + self.activation[id as usize / 64].load(Ordering::Relaxed) & (1 << (id % 64)) != 0 + } + + pub(crate) fn config(&self) -> EventConfig { + let _guard = self + .config_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.config_unlocked() + } + + fn config_unlocked(&self) -> EventConfig { + let ring_size = self + .ring + .read() + .unwrap_or_else(|error| error.into_inner()) + .len() as u32; + let words = [ + self.activation[0].load(Ordering::Acquire), + self.activation[1].load(Ordering::Acquire), + ]; + EventConfig { + ring_size, + activation: words_activation(words), + } + } + + pub(crate) fn set_config(&self, config: EventConfig) -> Result<(), String> { + validate_config(config)?; + let _guard = self + .config_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.set_config_unlocked(config); + Ok(()) + } + + pub(crate) fn set_config_if( + &self, + expected: EventConfig, + config: EventConfig, + ) -> Result { + validate_config(expected)?; + validate_config(config)?; + let _guard = self + .config_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + if self.config_unlocked() != expected { + return Ok(false); + } + self.set_config_unlocked(config); + Ok(true) + } + + fn set_config_unlocked(&self, config: EventConfig) { + if self.config_unlocked().ring_size != config.ring_size { + self.resize(config.ring_size); + } + let words = activation_words(config.activation); + self.activation[0].store(words[0], Ordering::Release); + self.activation[1].store(words[1], Ordering::Release); + self.changed + .send_replace(self.next_sequence.load(Ordering::Acquire)); + } + + fn resize(&self, size: u32) { + let mut guard = self.ring.write().unwrap_or_else(|error| error.into_inner()); + let old = Arc::clone(&guard); + let replacement = Arc::new(Ring::new(size)); + let edge = self.next_sequence.load(Ordering::Acquire); + let first = edge.saturating_sub(size as u64).max(1); + for sequence in first..edge { + if let Some(record) = old.slot(sequence).read(sequence) { + replacement.slot(sequence).write(record); + } + } + *guard = replacement; + } + + /// Attempts one bounded, allocation-free append. Failure means a reported sequence gap. + #[allow(clippy::too_many_arguments)] + #[inline] + pub(crate) fn record( + &self, + event: EventId, + flags: u16, + source: u8, + schema: u8, + connection: u64, + subject: u64, + args: [u64; 3], + ) -> bool { + if !self.enabled(event) { + return false; + } + let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + let Ok(ring) = self.ring.try_read() else { + self.dropped.fetch_add(1, Ordering::Relaxed); + self.changed.send_replace(sequence.saturating_add(1)); + return false; + }; + let record = EventRecord { + sequence, + monotonic_ns: self.monotonic_ns(), + event_id: event as u32, + flags, + source, + schema, + connection, + subject, + args, + }; + let written = ring.slot(sequence).write(record); + if written { + self.changed.send_replace(sequence.saturating_add(1)); + } else { + self.dropped.fetch_add(1, Ordering::Relaxed); + self.changed.send_replace(sequence.saturating_add(1)); + } + written + } + + pub(crate) fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } + + fn monotonic_ns(&self) -> u64 { + self.started.elapsed().as_nanos().min(u64::MAX as u128) as u64 + } + + pub(crate) fn oldest_sequence(&self) -> u64 { + let edge = self.next_sequence.load(Ordering::Acquire); + let size = self + .ring + .read() + .unwrap_or_else(|error| error.into_inner()) + .len(); + edge.saturating_sub(size).max(1) + } + + pub(crate) fn snapshot(&self, from_sequence: u64, limit: usize) -> EventSnapshot { + let ring = self.ring.read().unwrap_or_else(|error| error.into_inner()); + let edge = self.next_sequence.load(Ordering::Acquire); + let requested = from_sequence.max(1); + let retained = edge.saturating_sub(ring.len()).max(1); + let first = requested.max(retained).min(edge); + let overwritten = first.saturating_sub(requested); + let end = edge.min(first.saturating_add(limit as u64)); + let mut records = Vec::with_capacity((end - first) as usize); + let mut gaps = Vec::new(); + let mut gap_start = None; + for sequence in first..end { + if let Some(record) = ring.slot(sequence).read(sequence) { + if let Some(start) = gap_start.take() { + gaps.push(SequenceGap { + first_sequence: start, + next_sequence: sequence, + }); + } + records.push(record); + } else if gap_start.is_none() { + gap_start = Some(sequence); + } + } + if let Some(start) = gap_start { + gaps.push(SequenceGap { + first_sequence: start, + next_sequence: end, + }); + } + EventSnapshot { + first_sequence: first, + next_sequence: end, + overwritten, + gaps, + records, + } + } + + fn subscribe(&self) -> watch::Receiver { + self.changed.subscribe() + } +} + +fn validate_config(config: EventConfig) -> Result<(), String> { + if !(EVENTS_RING_MIN..=EVENTS_RING_MAX).contains(&config.ring_size) { + return Err(format!( + "event ring size must be in {EVENTS_RING_MIN}..={EVENTS_RING_MAX}" + )); + } + Ok(()) +} + +fn activation_words(activation: Activation) -> [u64; 2] { + [ + u64::from_le_bytes(activation.0[..8].try_into().unwrap()), + u64::from_le_bytes(activation.0[8..].try_into().unwrap()), + ] +} + +fn words_activation(words: [u64; 2]) -> Activation { + let mut bytes = [0; 16]; + bytes[..8].copy_from_slice(&words[0].to_le_bytes()); + bytes[8..].copy_from_slice(&words[1].to_le_bytes()); + Activation(bytes) +} + +struct GlobalEvents { + recorder: Arc, + startup_file: Option, +} + +static GLOBAL: OnceLock = OnceLock::new(); + +/// Installs startup configuration before any event macro observes the recorder. +pub(crate) fn initialize(config: EventStartupConfig) -> Result<&'static EventRecorder, String> { + let recorder = Arc::new(EventRecorder::new(config.config)?); + GLOBAL + .set(GlobalEvents { + recorder, + startup_file: config.file, + }) + .map_err(|_| "event recorder is already initialized".to_string())?; + Ok(global()) +} + +fn global_state() -> &'static GlobalEvents { + GLOBAL.get_or_init(|| GlobalEvents { + recorder: Arc::new(EventRecorder::default()), + startup_file: None, + }) +} + +pub(crate) fn global() -> &'static EventRecorder { + global_state().recorder.as_ref() +} + +pub(crate) fn global_arc() -> Arc { + Arc::clone(&global_state().recorder) +} + +pub(crate) fn startup_file() -> Option<&'static Path> { + global_state().startup_file.as_deref() +} + +#[cfg(test)] +pub(crate) fn global_file_streams() -> Arc { + static FILES: OnceLock> = OnceLock::new(); + FILES + .get_or_init(|| Arc::new(FileStreamManager::default())) + .clone() +} + +macro_rules! blit_event_enabled { + ($event:expr) => {{ $crate::events::global().enabled($event) }}; +} + +macro_rules! blit_event { + ($event:expr) => {{ + let event = $event; + if $crate::events::global().enabled(event) { + $crate::events::global().record(event, 0, 0, 0, 0, 0, [0, 0, 0]) + } else { + false + } + }}; + ($event:expr, $connection:expr, $subject:expr, $arg0:expr, $arg1:expr, $arg2:expr) => {{ + let event = $event; + if $crate::events::global().enabled(event) { + $crate::events::global().record( + event, + 0, + 0, + 0, + $connection, + $subject, + [$arg0, $arg1, $arg2], + ) + } else { + false + } + }}; + ($event:expr, flags: $flags:expr, source: $source:expr, schema: $schema:expr, + connection: $connection:expr, subject: $subject:expr, args: [$arg0:expr, $arg1:expr, $arg2:expr]) => {{ + let event = $event; + if $crate::events::global().enabled(event) { + $crate::events::global().record( + event, + $flags, + $source, + $schema, + $connection, + $subject, + [$arg0, $arg1, $arg2], + ) + } else { + false + } + }}; +} + +pub(crate) use blit_event; +pub(crate) use blit_event_enabled; + +pub(crate) struct DispatchGuard { + connection: u64, + opcode: u8, +} + +impl DispatchGuard { + pub(crate) fn new(connection: u64, opcode: u8, bytes: usize) -> Self { + blit_event!( + EventId::RawRequestDispatch, + connection, + opcode as u64, + bytes as u64, + 0, + 0 + ); + Self { connection, opcode } + } +} + +impl Drop for DispatchGuard { + fn drop(&mut self) { + blit_event!( + EventId::RawRequestDone, + self.connection, + self.opcode as u64, + 0, + 0, + 0 + ); + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EventConfigOverrides { + pub ring_bytes: Option, + pub events: Option, + pub file: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EventStartupConfig { + pub config: EventConfig, + pub file: Option, +} + +impl EventStartupConfig { + pub(crate) fn resolve(overrides: EventConfigOverrides) -> Result { + Self::resolve_with(overrides, |name| { + std::env::var(name).map(Some).or_else(|error| match error { + std::env::VarError::NotPresent => Ok(None), + std::env::VarError::NotUnicode(_) => Err(format!("{name} must be valid UTF-8")), + }) + }) + } + + fn resolve_with(overrides: EventConfigOverrides, read_env: F) -> Result + where + F: Fn(&str) -> Result, String>, + { + let bytes = match overrides.ring_bytes { + Some(bytes) => bytes, + None => read_env("BLIT_EVENTS_BYTES")? + .map(|value| parse_bytes(&value)) + .transpose()? + .unwrap_or(DEFAULT_RING_BYTES), + }; + if bytes % EVENT_RECORD_SIZE as u64 != 0 { + return Err(format!( + "BLIT_EVENTS_BYTES must be a multiple of {EVENT_RECORD_SIZE}" + )); + } + let records = bytes / EVENT_RECORD_SIZE as u64; + if !(EVENTS_RING_MIN as u64..=EVENTS_RING_MAX as u64).contains(&records) { + return Err(format!( + "BLIT_EVENTS_BYTES must select {EVENTS_RING_MIN}..={EVENTS_RING_MAX} records" + )); + } + let events = match overrides.events { + Some(value) => Some(value), + None => read_env("BLIT_EVENTS")?, + }; + let activation = events + .as_deref() + .map(parse_activation) + .transpose()? + .unwrap_or_else(default_activation); + let file = match overrides.file { + Some(path) => Some(path), + None => read_env("BLIT_EVENTS_FILE")? + .map(|value| { + if value.is_empty() { + Err("BLIT_EVENTS_FILE must not be empty".to_string()) + } else { + Ok(PathBuf::from(value)) + } + }) + .transpose()?, + }; + Ok(Self { + config: EventConfig { + ring_size: records as u32, + activation, + }, + file, + }) + } +} + +fn parse_bytes(input: &str) -> Result { + let trimmed = input.trim(); + let split = trimmed + .find(|character: char| !character.is_ascii_digit()) + .unwrap_or(trimmed.len()); + let number = trimmed[..split] + .parse::() + .map_err(|_| format!("invalid BLIT_EVENTS_BYTES value {input:?}"))?; + let suffix = trimmed[split..].trim().to_ascii_lowercase(); + let multiplier = match suffix.as_str() { + "" | "b" => 1, + "k" | "kb" | "kib" => 1024, + "m" | "mb" | "mib" => 1024 * 1024, + "g" | "gb" | "gib" => 1024 * 1024 * 1024, + _ => return Err(format!("invalid BLIT_EVENTS_BYTES suffix {suffix:?}")), + }; + number + .checked_mul(multiplier) + .ok_or_else(|| "BLIT_EVENTS_BYTES is too large".to_string()) +} + +fn parse_activation(input: &str) -> Result { + let selectors: Vec<_> = input + .split([',', ' ', '\t', '\n']) + .filter(|selector| !selector.is_empty()) + .collect(); + if selectors.is_empty() { + return Err("BLIT_EVENTS must contain at least one selector".to_string()); + } + let modifying = selectors[0].starts_with(['+', '-']); + let mut activation = if modifying { + default_activation() + } else { + Activation::NONE + }; + for raw in selectors { + let (active, selector) = match raw.as_bytes()[0] { + b'+' => (true, &raw[1..]), + b'-' => (false, &raw[1..]), + _ => (true, raw), + }; + if selector.is_empty() { + return Err("empty BLIT_EVENTS selector".to_string()); + } + match selector.to_ascii_lowercase().as_str() { + "all" => { + activation = if active { + Activation::ALL + } else { + Activation::NONE + } + } + "none" => { + activation = if active { + Activation::NONE + } else { + Activation::ALL + } + } + "default" => { + let defaults = default_activation(); + for event in EventId::ALL { + if defaults.contains(*event as u8) { + activation.set(*event as u8, active); + } + } + } + name => { + if let Some(family) = EventFamily::named(name) { + for event in EventId::ALL { + if event.family() == family { + activation.set(*event as u8, active); + } + } + } else if let Some(event) = EventId::named(name) { + activation.set(event as u8, active); + } else { + return Err(format!("unknown BLIT_EVENTS selector {selector:?}")); + } + } + } + } + Ok(activation) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FileStreamState { + Starting, + Running, + Stopped, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FileStreamStatus { + pub stream_id: u32, + pub state: FileStreamState, + pub records_written: u64, + pub bytes_written: u64, + pub detail: String, +} + +struct FileStreamProgress { + state: Mutex, + records_written: AtomicU64, + bytes_written: AtomicU64, + detail: Mutex, +} + +impl FileStreamProgress { + fn new() -> Self { + Self { + state: Mutex::new(FileStreamState::Starting), + records_written: AtomicU64::new(0), + bytes_written: AtomicU64::new(0), + detail: Mutex::new(String::new()), + } + } + + fn status(&self, stream_id: u32) -> FileStreamStatus { + FileStreamStatus { + stream_id, + state: *self.state.lock().unwrap_or_else(|error| error.into_inner()), + records_written: self.records_written.load(Ordering::Acquire), + bytes_written: self.bytes_written.load(Ordering::Acquire), + detail: self + .detail + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(), + } + } + + fn set_state(&self, state: FileStreamState, detail: impl Into) { + *self.state.lock().unwrap_or_else(|error| error.into_inner()) = state; + *self + .detail + .lock() + .unwrap_or_else(|error| error.into_inner()) = detail.into(); + } +} + +struct FileStream { + progress: Arc, + stop: watch::Sender, + task: JoinHandle<()>, +} + +pub(crate) struct FileStreamManager { + recorder: Arc, + streams: AsyncMutex>, + max_streams: usize, + startup_file: Option, +} + +impl Default for FileStreamManager { + fn default() -> Self { + Self::with_startup_file( + global_arc(), + MAX_FILE_STREAMS, + startup_file().map(Path::to_path_buf), + ) + } +} + +impl FileStreamManager { + pub(crate) fn new(recorder: Arc, max_streams: usize) -> Self { + Self::with_startup_file(recorder, max_streams, None) + } + + pub(crate) fn with_startup_file( + recorder: Arc, + max_streams: usize, + startup_file: Option, + ) -> Self { + Self { + recorder, + streams: AsyncMutex::new(HashMap::new()), + max_streams, + startup_file, + } + } + + /// Starts the file selected during initialization as stream zero. + pub(crate) async fn start_startup_file(&self) -> Result, String> { + match &self.startup_file { + Some(path) => self.start(0, path.clone(), 0).await.map(Some), + None => Ok(None), + } + } + + pub(crate) async fn start( + &self, + stream_id: u32, + path: PathBuf, + flags: u8, + ) -> Result { + if flags & !(FILE_APPEND | FILE_SYNC) != 0 { + return Err("invalid event file flags".to_string()); + } + let mut streams = self.streams.lock().await; + if streams.contains_key(&stream_id) { + return Err(format!("event file stream {stream_id} already exists")); + } + if streams.len() >= self.max_streams { + return Err("too many event file streams".to_string()); + } + let progress = Arc::new(FileStreamProgress::new()); + let (stop, receiver) = watch::channel(false); + let task_progress = Arc::clone(&progress); + let recorder = Arc::clone(&self.recorder); + let (opened_tx, opened_rx) = oneshot::channel(); + let task = tokio::spawn(async move { + if let Err(error) = file_stream_task( + &recorder, + &path, + flags, + receiver, + &task_progress, + Some(opened_tx), + ) + .await + { + task_progress.set_state(FileStreamState::Failed, error.to_string()); + } + }); + match opened_rx.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let _ = task.await; + return Err(error); + } + Err(_) => { + let _ = task.await; + return Err("event file stream ended before opening the file".to_string()); + } + } + let status = progress.status(stream_id); + streams.insert( + stream_id, + FileStream { + progress, + stop, + task, + }, + ); + Ok(status) + } + + pub(crate) async fn status(&self, stream_id: u32) -> Option { + self.streams + .lock() + .await + .get(&stream_id) + .map(|stream| stream.progress.status(stream_id)) + } + + pub(crate) async fn stop(&self, stream_id: u32) -> Result { + let stream = self + .streams + .lock() + .await + .remove(&stream_id) + .ok_or_else(|| format!("event file stream {stream_id} not found"))?; + let _ = stream.stop.send(true); + stream + .task + .await + .map_err(|error| format!("event file stream task failed: {error}"))?; + Ok(stream.progress.status(stream_id)) + } + + pub(crate) async fn shutdown(&self) -> Vec { + let streams = { + let mut guard = self.streams.lock().await; + guard.drain().collect::>() + }; + for (_, stream) in &streams { + let _ = stream.stop.send(true); + } + let mut statuses = Vec::with_capacity(streams.len()); + for (stream_id, stream) in streams { + let _ = stream.task.await; + statuses.push(stream.progress.status(stream_id)); + } + statuses + } +} + +const CLIENT_STREAM_PACKET_RECORDS: usize = 256; +const MAX_CLIENT_STREAMS: usize = 8; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ClientStreamState { + Starting, + Replaying, + Following, + Stopped, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientStreamStatus { + pub connection: u64, + pub stream_id: u32, + pub state: ClientStreamState, + pub next_sequence: u64, + pub records_sent: u64, + pub gaps: u64, + pub detail: String, +} + +struct ClientStreamProgress { + state: Mutex, + next_sequence: AtomicU64, + records_sent: AtomicU64, + gaps: AtomicU64, + detail: Mutex, +} + +impl ClientStreamProgress { + fn new(from_sequence: u64) -> Self { + Self { + state: Mutex::new(ClientStreamState::Starting), + next_sequence: AtomicU64::new(from_sequence), + records_sent: AtomicU64::new(0), + gaps: AtomicU64::new(0), + detail: Mutex::new(String::new()), + } + } + + fn status(&self, connection: u64, stream_id: u32) -> ClientStreamStatus { + ClientStreamStatus { + connection, + stream_id, + state: *self.state.lock().unwrap_or_else(|error| error.into_inner()), + next_sequence: self.next_sequence.load(Ordering::Acquire), + records_sent: self.records_sent.load(Ordering::Acquire), + gaps: self.gaps.load(Ordering::Acquire), + detail: self + .detail + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(), + } + } + + fn set_state(&self, state: ClientStreamState, detail: impl Into) { + *self.state.lock().unwrap_or_else(|error| error.into_inner()) = state; + *self + .detail + .lock() + .unwrap_or_else(|error| error.into_inner()) = detail.into(); + } +} + +struct ClientStream { + progress: Arc, + stop: watch::Sender, + task: JoinHandle<()>, +} + +/// Owns all event streams for one client connection. +pub(crate) struct ClientStreamManager { + connection: u64, + recorder: Arc, + sender: mpsc::Sender>, + streams: AsyncMutex>, + max_streams: usize, +} + +impl ClientStreamManager { + pub(crate) fn new( + connection: u64, + recorder: Arc, + sender: mpsc::Sender>, + ) -> Self { + Self::with_limit(connection, recorder, sender, MAX_CLIENT_STREAMS) + } + + pub(crate) fn with_limit( + connection: u64, + recorder: Arc, + sender: mpsc::Sender>, + max_streams: usize, + ) -> Self { + Self { + connection, + recorder, + sender, + streams: AsyncMutex::new(HashMap::new()), + max_streams, + } + } + + pub(crate) async fn start( + &self, + _request_id: u32, + stream_id: u32, + from_sequence: u64, + flags: u8, + ) -> Result { + let (status, start) = self.prepare_start(stream_id, from_sequence, flags).await?; + let _ = start.send(()); + Ok(status) + } + + async fn prepare_start( + &self, + stream_id: u32, + from_sequence: u64, + flags: u8, + ) -> Result<(ClientStreamStatus, oneshot::Sender<()>), String> { + if flags & !STREAM_FLAGS != 0 { + return Err("invalid event stream flags".to_string()); + } + let mut streams = self.streams.lock().await; + if streams.contains_key(&stream_id) { + return Err(format!("event client stream {stream_id} already exists")); + } + if streams.len() >= self.max_streams { + return Err("too many event client streams".to_string()); + } + let cursor = if from_sequence == 0 { + self.recorder.oldest_sequence() + } else if from_sequence == u64::MAX { + self.recorder.next_sequence.load(Ordering::Acquire) + } else { + from_sequence + }; + let progress = Arc::new(ClientStreamProgress::new(cursor)); + let (stop, receiver) = watch::channel(false); + let recorder = Arc::clone(&self.recorder); + let sender = self.sender.clone(); + let task_progress = Arc::clone(&progress); + let follow = flags & STREAM_FOLLOW != 0; + let (start, begin) = oneshot::channel(); + let task = tokio::spawn(async move { + if begin.await.is_err() { + task_progress.set_state(ClientStreamState::Stopped, ""); + return; + } + client_stream_task( + recorder, + sender, + stream_id, + cursor, + follow, + receiver, + task_progress, + ) + .await; + }); + let status = progress.status(self.connection, stream_id); + streams.insert( + stream_id, + ClientStream { + progress, + stop, + task, + }, + ); + Ok((status, start)) + } + + pub(crate) async fn status(&self, stream_id: u32) -> Option { + self.streams + .lock() + .await + .get(&stream_id) + .map(|stream| stream.progress.status(self.connection, stream_id)) + } + + pub(crate) async fn stop(&self, stream_id: u32) -> Result { + let stream = self + .streams + .lock() + .await + .remove(&stream_id) + .ok_or_else(|| format!("event client stream {stream_id} not found"))?; + let _ = stream.stop.send(true); + stream + .task + .await + .map_err(|error| format!("event client stream task failed: {error}"))?; + Ok(stream.progress.status(self.connection, stream_id)) + } + + pub(crate) async fn shutdown(&self) -> Vec { + let streams = { + let mut guard = self.streams.lock().await; + guard.drain().collect::>() + }; + for (_, stream) in &streams { + let _ = stream.stop.send(true); + } + let mut statuses = Vec::with_capacity(streams.len()); + for (stream_id, stream) in streams { + let _ = stream.task.await; + statuses.push(stream.progress.status(self.connection, stream_id)); + } + statuses + } +} + +fn operation_status(error: &str) -> u8 { + if error.ends_with("not found") { + blit_remote::STATUS_NOT_FOUND + } else { + blit_remote::STATUS_OTHER + } +} + +async fn send_protocol(sender: &mpsc::Sender>, packet: Vec) { + let _ = sender.send(packet).await; +} + +/// Handles one `blit.events.v1` request without taking the session mutex. +/// File opens, writes, flushes, and joins remain in spawned file tasks. +pub(crate) async fn dispatch( + packet: &[u8], + recorder: &Arc, + client_streams: &Arc, + file_streams: &Arc, + sender: &mpsc::Sender>, +) { + let request = match parse_event_request(packet) { + Ok(request) => request, + Err(error) => { + if let Some(kind) = packet.get(2).copied() + && let Some(reply) = error.status_reply(kind) + { + send_protocol(sender, reply).await; + } + return; + } + }; + match request { + EventRequest::ConfigGet { request_id } => { + let reply = msg_event_config(request_id, blit_remote::STATUS_OK, recorder.config()) + .expect("recorder always has a valid event config"); + send_protocol(sender, reply).await; + } + EventRequest::ConfigSet { request_id, config } => { + let status = recorder + .set_config(config) + .map(|()| blit_remote::STATUS_OK) + .unwrap_or(blit_remote::STATUS_INVALID); + if status == blit_remote::STATUS_OK { + let words = activation_words(config.activation); + recorder.record( + EventId::ConfigChanged, + 0, + 0, + 0, + 0, + request_id as u64, + [config.ring_size as u64, words[0], words[1]], + ); + } + let reply = msg_event_config(request_id, status, recorder.config()) + .expect("recorder always has a valid event config"); + send_protocol(sender, reply).await; + } + EventRequest::ConfigSetIf { + request_id, + expected, + config, + } => { + let status = match recorder.set_config_if(expected, config) { + Ok(true) => blit_remote::STATUS_OK, + Ok(false) => blit_remote::STATUS_CONFLICT, + Err(_) => blit_remote::STATUS_INVALID, + }; + if status == blit_remote::STATUS_OK { + let words = activation_words(config.activation); + recorder.record( + EventId::ConfigChanged, + 0, + 0, + 0, + 0, + request_id as u64, + [config.ring_size as u64, words[0], words[1]], + ); + } + let reply = msg_event_config(request_id, status, recorder.config()) + .expect("recorder always has a valid event config"); + send_protocol(sender, reply).await; + } + EventRequest::Dump { + request_id, + from_sequence, + limit, + } => { + let snapshot = recorder.snapshot(from_sequence, limit as usize); + let status = if snapshot.overwritten != 0 || !snapshot.gaps.is_empty() { + blit_remote::STATUS_BUDGET + } else { + blit_remote::STATUS_OK + }; + let reply = msg_event_dump( + request_id, + status, + snapshot.first_sequence, + snapshot.next_sequence, + &snapshot.records, + ) + .expect("request decoder bounded the event dump"); + send_protocol(sender, reply).await; + } + EventRequest::StreamStart { + request_id, + stream_id, + from_sequence, + flags, + } => match client_streams + .prepare_start(stream_id, from_sequence, flags) + .await + { + Ok((status, start)) => { + send_protocol( + sender, + msg_event_stream_status( + request_id, + blit_remote::STATUS_OK, + stream_id, + status.next_sequence, + ), + ) + .await; + let _ = start.send(()); + } + Err(error) => { + send_protocol( + sender, + msg_event_stream_status( + request_id, + operation_status(&error), + stream_id, + from_sequence, + ), + ) + .await; + } + }, + EventRequest::StreamStop { + request_id, + stream_id, + } => { + let result = client_streams.stop(stream_id).await; + let (status, next_sequence) = match result { + Ok(status) => (blit_remote::STATUS_OK, status.next_sequence), + Err(error) => (operation_status(&error), 0), + }; + send_protocol( + sender, + msg_event_stream_status(request_id, status, stream_id, next_sequence), + ) + .await; + } + EventRequest::FileStart { + request_id, + stream_id, + flags, + path, + } => { + let result = file_streams + .start(stream_id, PathBuf::from(path), flags) + .await; + let (status, records, bytes, detail) = match result { + Ok(status) => ( + blit_remote::STATUS_OK, + status.records_written, + status.bytes_written, + status.detail, + ), + Err(error) => (operation_status(&error), 0, 0, error), + }; + let reply = + msg_event_file_status(request_id, status, stream_id, records, bytes, &detail) + .expect("bounded event file status detail"); + send_protocol(sender, reply).await; + } + EventRequest::FileStop { + request_id, + stream_id, + } => { + let result = file_streams.stop(stream_id).await; + let (status, records, bytes, detail) = match result { + Ok(status) => ( + blit_remote::STATUS_OK, + status.records_written, + status.bytes_written, + status.detail, + ), + Err(error) => (operation_status(&error), 0, 0, error), + }; + if let Ok(reply) = + msg_event_file_status(request_id, status, stream_id, records, bytes, &detail) + { + send_protocol(sender, reply).await; + } + } + } +} + +async fn send_stream_packet( + sender: &mpsc::Sender>, + packet: Vec, + stop: &mut watch::Receiver, +) -> Result<(), &'static str> { + tokio::select! { + result = sender.send(packet) => result.map_err(|_| "client writer closed"), + result = stop.changed() => { + if result.is_err() || *stop.borrow() { + Err("stream stopped") + } else { + Ok(()) + } + } + } +} + +async fn client_stream_task( + recorder: Arc, + sender: mpsc::Sender>, + stream_id: u32, + mut cursor: u64, + follow: bool, + mut stop: watch::Receiver, + progress: Arc, +) { + progress.set_state(ClientStreamState::Replaying, ""); + let mut changed = recorder.subscribe(); + let mut announced_live = false; + loop { + if *stop.borrow() { + progress.set_state(ClientStreamState::Stopped, ""); + return; + } + let snapshot = recorder.snapshot(cursor, CLIENT_STREAM_PACKET_RECORDS); + let missing = snapshot.overwritten + + snapshot + .gaps + .iter() + .map(|gap| gap.next_sequence - gap.first_sequence) + .sum::(); + if missing != 0 { + progress.gaps.fetch_add(missing, Ordering::Release); + recorder.record( + EventId::StreamGap, + 0, + 0, + 0, + 0, + stream_id as u64, + [missing, snapshot.first_sequence, snapshot.next_sequence], + ); + let gap_edge = snapshot + .gaps + .last() + .map_or(snapshot.first_sequence, |gap| gap.next_sequence); + let packet = + msg_event_stream_status(0, blit_remote::STATUS_BUDGET, stream_id, gap_edge); + if let Err(error) = send_stream_packet(&sender, packet, &mut stop).await { + let state = if error == "stream stopped" { + ClientStreamState::Stopped + } else { + ClientStreamState::Failed + }; + progress.set_state(state, error); + return; + } + } + if !snapshot.records.is_empty() { + let count = snapshot.records.len() as u64; + let packet = + msg_event_stream_data(stream_id, recorder.monotonic_ns(), &snapshot.records) + .expect("stream packet is capped below the codec limit"); + if let Err(error) = send_stream_packet(&sender, packet, &mut stop).await { + let state = if error == "stream stopped" { + ClientStreamState::Stopped + } else { + ClientStreamState::Failed + }; + progress.set_state(state, error); + return; + } + progress.records_sent.fetch_add(count, Ordering::Release); + } + cursor = snapshot.next_sequence; + progress.next_sequence.store(cursor, Ordering::Release); + if cursor < recorder.next_sequence.load(Ordering::Acquire) { + continue; + } + if !follow { + progress.set_state(ClientStreamState::Stopped, ""); + return; + } + progress.set_state(ClientStreamState::Following, ""); + if !announced_live { + let packet = msg_event_stream_status(0, blit_remote::STATUS_OK, stream_id, cursor); + if let Err(error) = send_stream_packet(&sender, packet, &mut stop).await { + let state = if error == "stream stopped" { + ClientStreamState::Stopped + } else { + ClientStreamState::Failed + }; + progress.set_state(state, error); + return; + } + announced_live = true; + } + tokio::select! { + result = changed.changed() => { + if result.is_err() { + progress.set_state(ClientStreamState::Failed, "event recorder closed"); + return; + } + }, + result = stop.changed() => { + if result.is_err() || *stop.borrow() { + progress.set_state(ClientStreamState::Stopped, ""); + return; + } + } + } + } +} + +async fn open_event_file( + path: &Path, + flags: u8, + progress: &FileStreamProgress, +) -> io::Result { + let append = flags & FILE_APPEND != 0; + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + if !append { + options.truncate(true); + } + let mut file = options.open(path).await?; + let length = file.metadata().await?.len(); + if append && length != 0 { + if length < EVENT_FILE_HEADER_SIZE as u64 + || !(length - EVENT_FILE_HEADER_SIZE as u64).is_multiple_of(EVENT_RECORD_SIZE as u64) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "existing event file has an invalid length", + )); + } + let mut header = [0; EVENT_FILE_HEADER_SIZE]; + file.read_exact(&mut header).await?; + EventFileHeader::decode(&header) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + file.seek(std::io::SeekFrom::End(0)).await?; + } else { + file.write_all(&EventFileHeader::CANONICAL.encode()).await?; + progress + .bytes_written + .fetch_add(EVENT_FILE_HEADER_SIZE as u64, Ordering::Release); + } + Ok(file) +} + +async fn file_stream_task( + recorder: &EventRecorder, + path: &Path, + flags: u8, + mut stop: watch::Receiver, + progress: &FileStreamProgress, + opened: Option>>, +) -> io::Result<()> { + let mut file = match open_event_file(path, flags, progress).await { + Ok(file) => { + progress.set_state(FileStreamState::Running, ""); + if let Some(opened) = opened { + let _ = opened.send(Ok(())); + } + file + } + Err(error) => { + if let Some(opened) = opened { + let _ = opened.send(Err(error.to_string())); + } + return Err(error); + } + }; + let mut changed = recorder.subscribe(); + let mut cursor = recorder.oldest_sequence(); + let mut stop_edge = None; + loop { + if *stop.borrow() && stop_edge.is_none() { + stop_edge = Some(recorder.next_sequence.load(Ordering::Acquire)); + } + let snapshot = recorder.snapshot(cursor, 1024); + if !snapshot.records.is_empty() { + for record in &snapshot.records { + file.write_all(&record.encode()).await?; + } + let count = snapshot.records.len() as u64; + progress.records_written.fetch_add(count, Ordering::Release); + progress + .bytes_written + .fetch_add(count * EVENT_RECORD_SIZE as u64, Ordering::Release); + } + cursor = snapshot.next_sequence; + if flags & FILE_SYNC != 0 && !snapshot.records.is_empty() { + file.sync_data().await?; + } + if stop_edge.is_some_and(|edge| cursor >= edge) { + break; + } + if cursor < recorder.next_sequence.load(Ordering::Acquire) { + continue; + } + tokio::select! { + result = changed.changed() => { + if result.is_err() { + break; + } + }, + result = stop.changed() => { + if result.is_err() || *stop.borrow() { + stop_edge.get_or_insert_with(|| recorder.next_sequence.load(Ordering::Acquire)); + } + } + } + } + file.flush().await?; + if flags & FILE_SYNC != 0 { + file.sync_data().await?; + } + progress.set_state(FileStreamState::Stopped, ""); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + use std::thread; + use std::time::Duration; + + fn recorder(size: u32, activation: Activation) -> EventRecorder { + EventRecorder::new(EventConfig { + ring_size: size, + activation, + }) + .unwrap() + } + + fn all_recorder(size: u32) -> EventRecorder { + recorder(size, Activation::ALL) + } + + fn record_value(recorder: &EventRecorder, value: u64) { + assert!(recorder.record(EventId::RawRequestRead, 2, 3, 4, 5, 6, [value, 8, 9])); + } + + #[test] + fn default_is_one_mib_and_only_low_rate_lifecycle() { + let recorder = EventRecorder::default(); + assert_eq!(recorder.config().ring_size, 16_384); + assert!(recorder.enabled(EventId::ServerStarted)); + assert!(recorder.enabled(EventId::ServerStopping)); + assert!(recorder.enabled(EventId::ClientConnected)); + assert!(!recorder.enabled(EventId::RawRequestRead)); + assert!(!recorder.enabled(EventId::PtyRead)); + } + + #[test] + fn record_round_trips_all_fields() { + let recorder = all_recorder(4); + record_value(&recorder, 7); + let snapshot = recorder.snapshot(1, 10); + assert_eq!(snapshot.first_sequence, 1); + assert_eq!(snapshot.next_sequence, 2); + assert_eq!(snapshot.gaps, vec![]); + let record = snapshot.records[0]; + assert_eq!(record.sequence, 1); + assert_eq!(record.event_id, EventId::RawRequestRead as u32); + assert_eq!(record.flags, 2); + assert_eq!(record.source, 3); + assert_eq!(record.schema, 4); + assert_eq!(record.connection, 5); + assert_eq!(record.subject, 6); + assert_eq!(record.args, [7, 8, 9]); + } + + #[test] + fn ring_reports_overwrite_and_returns_ordered_records() { + let recorder = all_recorder(3); + for value in 0..6 { + record_value(&recorder, value); + } + let snapshot = recorder.snapshot(1, 10); + assert_eq!(snapshot.first_sequence, 4); + assert_eq!(snapshot.next_sequence, 7); + assert_eq!(snapshot.overwritten, 3); + assert_eq!( + snapshot + .records + .iter() + .map(|record| record.sequence) + .collect::>(), + vec![4, 5, 6] + ); + assert_eq!( + snapshot + .records + .iter() + .map(|record| record.args[0]) + .collect::>(), + vec![3, 4, 5] + ); + } + + #[test] + fn snapshot_reports_complete_record_gaps() { + let recorder = all_recorder(4); + record_value(&recorder, 1); + recorder.next_sequence.fetch_add(2, Ordering::Relaxed); + record_value(&recorder, 4); + let snapshot = recorder.snapshot(1, 10); + assert_eq!(snapshot.records.len(), 2); + assert_eq!( + snapshot.gaps, + vec![SequenceGap { + first_sequence: 2, + next_sequence: 4, + }] + ); + } + + #[test] + fn resize_preserves_newest_records() { + let recorder = all_recorder(5); + for value in 0..5 { + record_value(&recorder, value); + } + recorder + .set_config(EventConfig { + ring_size: 3, + activation: Activation::ALL, + }) + .unwrap(); + let snapshot = recorder.snapshot(1, 10); + assert_eq!(snapshot.overwritten, 2); + assert_eq!( + snapshot + .records + .iter() + .map(|record| record.args[0]) + .collect::>(), + vec![2, 3, 4] + ); + recorder + .set_config(EventConfig { + ring_size: 8, + activation: Activation::ALL, + }) + .unwrap(); + assert_eq!(recorder.snapshot(1, 10).records.len(), 3); + } + + #[test] + fn activation_can_change_at_runtime() { + let recorder = recorder(4, Activation::NONE); + assert!(!recorder.record(EventId::PtyCreateRegistered, 0, 0, 0, 0, 0, [0; 3])); + let mut activation = Activation::NONE; + activation.set(EventId::PtyCreateRegistered as u8, true); + recorder + .set_config(EventConfig { + ring_size: 4, + activation, + }) + .unwrap(); + assert!(recorder.record(EventId::PtyCreateRegistered, 0, 0, 0, 0, 0, [0; 3])); + } + + #[test] + fn conditional_config_set_is_atomic() { + let recorder = recorder(4, Activation::NONE); + let initial = recorder.config(); + let replacement = EventConfig { + ring_size: 8, + activation: Activation::ALL, + }; + assert_eq!(recorder.set_config_if(initial, replacement), Ok(true)); + assert_eq!(recorder.config(), replacement); + assert_eq!(recorder.set_config_if(initial, initial), Ok(false)); + assert_eq!(recorder.config(), replacement); + } + + #[test] + fn activation_parser_supports_events_families_and_modifiers() { + let activation = parse_activation("none,pty,+task-failed").unwrap(); + assert!(activation.contains(EventId::PtyCreateRegistered as u8)); + assert!(activation.contains(EventId::PtyRead as u8)); + assert!(activation.contains(EventId::TaskFailed as u8)); + assert!(!activation.contains(EventId::ServerStarted as u8)); + + let activation = parse_activation("-config-changed,+request").unwrap(); + assert!(activation.contains(EventId::ServerStarted as u8)); + assert!(!activation.contains(EventId::ConfigChanged as u8)); + assert!(activation.contains(EventId::RawRequestDone as u8)); + assert!(parse_activation("wat").is_err()); + } + + #[test] + fn environment_resolution_honors_typed_overrides() { + let config = EventStartupConfig::resolve_with( + EventConfigOverrides { + ring_bytes: Some(128), + events: Some("process-exit".to_string()), + file: Some(PathBuf::from("override.events")), + }, + |name| { + Ok(match name { + "BLIT_EVENTS_BYTES" => Some("1MiB".to_string()), + "BLIT_EVENTS" => Some("all".to_string()), + "BLIT_EVENTS_FILE" => Some("env.events".to_string()), + _ => None, + }) + }, + ) + .unwrap(); + assert_eq!(config.config.ring_size, 2); + assert!( + config + .config + .activation + .contains(EventId::ProcessExit as u8) + ); + assert!(!config.config.activation.contains(EventId::PtyRead as u8)); + assert_eq!(config.file, Some(PathBuf::from("override.events"))); + assert_eq!(parse_bytes("1MiB").unwrap(), DEFAULT_RING_BYTES); + assert!(parse_bytes("63").is_ok()); + } + + #[test] + fn invalid_ring_configuration_is_rejected() { + assert!( + EventRecorder::new(EventConfig { + ring_size: 0, + activation: Activation::NONE + }) + .is_err() + ); + assert!( + EventStartupConfig::resolve_with( + EventConfigOverrides { + ring_bytes: Some(65), + ..Default::default() + }, + |_| Ok(None) + ) + .is_err() + ); + } + + #[test] + fn disabled_record_does_not_advance_sequence() { + let recorder = recorder(2, Activation::NONE); + assert!(!recorder.record(EventId::PtyRead, 0, 0, 0, 0, 0, [0; 3])); + assert_eq!(recorder.next_sequence.load(Ordering::Relaxed), 1); + } + + #[test] + fn disabled_macro_arguments_are_not_evaluated() { + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn argument() -> u64 { + CALLS.fetch_add(1, Ordering::Relaxed); + 1 + } + assert!(!blit_event_enabled!(EventId::PtyRead)); + assert!(!blit_event!( + EventId::PtyRead, + argument(), + argument(), + argument(), + argument(), + argument() + )); + assert_eq!(CALLS.load(Ordering::Relaxed), 0); + } + + #[test] + fn concurrent_writers_produce_complete_globally_ordered_records() { + let recorder = Arc::new(all_recorder(8192)); + let writers = 8; + let per_writer = 500; + let mut threads = Vec::new(); + for writer in 0..writers { + let recorder = Arc::clone(&recorder); + threads.push(thread::spawn(move || { + for value in 0..per_writer { + while !recorder.record( + EventId::TaskCompleted, + 0, + writer as u8, + 0, + writer, + value, + [writer, value, writer ^ value], + ) { + thread::yield_now(); + } + } + })); + } + for thread in threads { + thread.join().unwrap(); + } + let snapshot = recorder.snapshot(1, writers as usize * per_writer as usize); + assert!(snapshot.gaps.is_empty()); + assert_eq!( + snapshot.records.len(), + writers as usize * per_writer as usize + ); + for (index, record) in snapshot.records.iter().enumerate() { + assert_eq!(record.sequence, index as u64 + 1); + assert_eq!(record.args[2], record.args[0] ^ record.args[1]); + assert_eq!(record.source as u64, record.connection); + } + } + + #[tokio::test] + async fn notification_wakes_a_consumer() { + let recorder = all_recorder(4); + let mut changed = recorder.subscribe(); + record_value(&recorder, 1); + tokio::time::timeout(Duration::from_secs(1), changed.changed()) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn file_stream_writes_header_records_and_flushes_on_stop() { + let recorder = Arc::new(all_recorder(16)); + record_value(&recorder, 1); + let manager = FileStreamManager::new(Arc::clone(&recorder), 1); + let path = std::env::temp_dir().join(format!( + "blit-events-{}-{}.bin", + std::process::id(), + recorder.started.elapsed().as_nanos() + )); + manager.start(7, path.clone(), 0).await.unwrap(); + for _ in 0..100 { + if manager + .status(7) + .await + .is_some_and(|status| status.records_written == 1) + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let status = manager.stop(7).await.unwrap(); + assert_eq!(status.state, FileStreamState::Stopped); + assert_eq!(status.records_written, 1); + let bytes = tokio::fs::read(&path).await.unwrap(); + EventFileHeader::decode(&bytes[..EVENT_FILE_HEADER_SIZE]).unwrap(); + assert_eq!(bytes.len(), EVENT_FILE_HEADER_SIZE + EVENT_RECORD_SIZE); + let record = EventRecord::decode(&bytes[EVENT_FILE_HEADER_SIZE..]).unwrap(); + assert_eq!(record.args[0], 1); + let _ = tokio::fs::remove_file(path).await; + } + + #[tokio::test] + async fn client_stream_replays_then_follows_notifications() { + use blit_remote::events::{EventMessage, parse_event_message}; + + let recorder = Arc::new(all_recorder(16)); + record_value(&recorder, 1); + let (sender, mut packets) = mpsc::channel(2); + let manager = ClientStreamManager::new(44, Arc::clone(&recorder), sender); + manager.start(7, 9, 1, STREAM_FOLLOW).await.unwrap(); + + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + let EventMessage::StreamData { + stream_id, records, .. + } = parse_event_message(&packet).unwrap() + else { + panic!("expected stream data"); + }; + assert_eq!(stream_id, 9); + assert_eq!( + records + .iter() + .map(|record| record.args[0]) + .collect::>(), + vec![1] + ); + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + parse_event_message(&packet).unwrap(), + EventMessage::StreamStatus { + request_id: 0, + status: blit_remote::STATUS_OK, + stream_id: 9, + .. + } + )); + + record_value(&recorder, 2); + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + let EventMessage::StreamData { records, .. } = parse_event_message(&packet).unwrap() else { + panic!("expected stream data"); + }; + assert_eq!(records[0].args[0], 2); + + let statuses = manager.shutdown().await; + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].connection, 44); + assert_eq!(statuses[0].state, ClientStreamState::Stopped); + assert_eq!(statuses[0].records_sent, 2); + } + + #[tokio::test] + async fn client_stream_reports_replay_gaps_before_data() { + use blit_remote::events::{EventMessage, parse_event_message}; + + let recorder = Arc::new(all_recorder(2)); + for value in 0..4 { + record_value(&recorder, value); + } + let (sender, mut packets) = mpsc::channel(2); + let manager = ClientStreamManager::new(1, Arc::clone(&recorder), sender); + manager.start(11, 12, 1, 0).await.unwrap(); + + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + parse_event_message(&packet).unwrap(), + EventMessage::StreamStatus { + request_id: 0, + status: blit_remote::STATUS_BUDGET, + stream_id: 12, + next_sequence: 3, + } + )); + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + let EventMessage::StreamData { records, .. } = parse_event_message(&packet).unwrap() else { + panic!("expected stream data"); + }; + assert_eq!( + records + .iter() + .map(|record| record.sequence) + .collect::>(), + vec![3, 4] + ); + + let status = manager.stop(12).await.unwrap(); + assert_eq!(status.gaps, 2); + assert_eq!(status.next_sequence, 5); + } + + #[tokio::test] + async fn client_stream_shutdown_cancels_a_blocked_writer() { + let recorder = Arc::new(all_recorder(4)); + record_value(&recorder, 1); + let (sender, _packets) = mpsc::channel(1); + sender.send(vec![0]).await.unwrap(); + let manager = ClientStreamManager::new(1, recorder, sender); + manager.start(1, 2, 1, STREAM_FOLLOW).await.unwrap(); + + let statuses = tokio::time::timeout(Duration::from_secs(1), manager.shutdown()) + .await + .unwrap(); + assert_eq!(statuses[0].state, ClientStreamState::Stopped); + } + + #[tokio::test] + async fn protocol_dispatch_correlates_config_dump_and_errors() { + use blit_remote::events::{ + EventMessage, msg_config_get, msg_config_set_if, msg_dump, parse_event_message, + }; + + let recorder = Arc::new(all_recorder(8)); + record_value(&recorder, 17); + let (sender, mut packets) = mpsc::channel(8); + let client_streams = Arc::new(ClientStreamManager::new( + 9, + Arc::clone(&recorder), + sender.clone(), + )); + let file_streams = Arc::new(FileStreamManager::new(Arc::clone(&recorder), 1)); + + dispatch( + &msg_config_get(41), + &recorder, + &client_streams, + &file_streams, + &sender, + ) + .await; + assert!(matches!( + parse_event_message(&packets.recv().await.unwrap()).unwrap(), + EventMessage::Config { + request_id: 41, + status: blit_remote::STATUS_OK, + .. + } + )); + + let initial = recorder.config(); + let replacement = EventConfig { + ring_size: 16, + activation: Activation::NONE, + }; + dispatch( + &msg_config_set_if(44, initial, replacement).unwrap(), + &recorder, + &client_streams, + &file_streams, + &sender, + ) + .await; + assert!(matches!( + parse_event_message(&packets.recv().await.unwrap()).unwrap(), + EventMessage::Config { + request_id: 44, + status: blit_remote::STATUS_OK, + config, + } if config == replacement + )); + dispatch( + &msg_config_set_if(45, initial, initial).unwrap(), + &recorder, + &client_streams, + &file_streams, + &sender, + ) + .await; + assert!(matches!( + parse_event_message(&packets.recv().await.unwrap()).unwrap(), + EventMessage::Config { + request_id: 45, + status: blit_remote::STATUS_CONFLICT, + config, + } if config == replacement + )); + + dispatch( + &msg_dump(42, 1, 4).unwrap(), + &recorder, + &client_streams, + &file_streams, + &sender, + ) + .await; + assert!(matches!( + parse_event_message(&packets.recv().await.unwrap()).unwrap(), + EventMessage::Dump { + request_id: 42, + status: blit_remote::STATUS_OK, + ref records, + .. + } if records.len() == 1 && records[0].args[0] == 17 + )); + + let mut malformed = msg_config_get(43); + malformed.push(0); + dispatch( + &malformed, + &recorder, + &client_streams, + &file_streams, + &sender, + ) + .await; + assert!(matches!( + parse_event_message(&packets.recv().await.unwrap()).unwrap(), + EventMessage::Status { + request_id: 43, + request_kind: C2S_CONFIG_GET, + status: blit_remote::STATUS_INVALID, + } + )); + } + + #[tokio::test] + async fn file_stream_limit_append_validation_and_shutdown() { + let recorder = Arc::new(all_recorder(4)); + let manager = FileStreamManager::new(Arc::clone(&recorder), 1); + let base = std::env::temp_dir().join(format!( + "blit-events-limit-{}-{}", + std::process::id(), + recorder.started.elapsed().as_nanos() + )); + manager.start(1, base.clone(), 0).await.unwrap(); + assert!( + manager + .start(2, base.with_extension("two"), 0) + .await + .is_err() + ); + let statuses = manager.shutdown().await; + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].state, FileStreamState::Stopped); + + tokio::fs::write(&base, b"not an event file").await.unwrap(); + let manager = FileStreamManager::new(Arc::clone(&recorder), 1); + assert!(manager.start(3, base.clone(), FILE_APPEND).await.is_err()); + assert!(manager.status(3).await.is_none()); + let _ = tokio::fs::remove_file(base).await; + } + + #[tokio::test] + async fn one_append_wakes_every_client_stream() { + use blit_remote::events::{EventMessage, parse_event_message}; + + let recorder = Arc::new(all_recorder(8)); + let (sender_a, mut packets_a) = mpsc::channel(4); + let (sender_b, mut packets_b) = mpsc::channel(4); + let manager_a = ClientStreamManager::new(1, Arc::clone(&recorder), sender_a); + let manager_b = ClientStreamManager::new(2, Arc::clone(&recorder), sender_b); + manager_a + .start(1, 10, u64::MAX, STREAM_FOLLOW) + .await + .unwrap(); + manager_b + .start(2, 20, u64::MAX, STREAM_FOLLOW) + .await + .unwrap(); + + for (stream_id, packets) in [(10, &mut packets_a), (20, &mut packets_b)] { + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + parse_event_message(&packet).unwrap(), + EventMessage::StreamStatus { + request_id: 0, + status: blit_remote::STATUS_OK, + stream_id: reply_stream, + .. + } if reply_stream == stream_id + )); + } + + record_value(&recorder, 77); + for packets in [&mut packets_a, &mut packets_b] { + let packet = tokio::time::timeout(Duration::from_secs(1), packets.recv()) + .await + .unwrap() + .unwrap(); + let EventMessage::StreamData { records, .. } = parse_event_message(&packet).unwrap() + else { + panic!("expected stream data"); + }; + assert_eq!(records.len(), 1); + assert_eq!(records[0].args[0], 77); + } + + manager_a.shutdown().await; + manager_b.shutdown().await; + } + + #[tokio::test] + async fn file_start_validates_before_success_and_stop_frees_the_slot() { + let recorder = Arc::new(all_recorder(8)); + let manager = FileStreamManager::new(Arc::clone(&recorder), 1); + let base = std::env::temp_dir().join(format!( + "blit-events-stop-{}-{}", + std::process::id(), + recorder.started.elapsed().as_nanos() + )); + + assert!(manager.start(1, std::env::temp_dir(), 0).await.is_err()); + assert!(manager.status(1).await.is_none()); + + manager.start(2, base.clone(), 0).await.unwrap(); + record_value(&recorder, 91); + let stopped = manager.stop(2).await.unwrap(); + assert_eq!(stopped.state, FileStreamState::Stopped); + assert_eq!(stopped.records_written, 1); + + manager.start(3, base.clone(), 0).await.unwrap(); + let stopped = manager.stop(3).await.unwrap(); + assert_eq!(stopped.state, FileStreamState::Stopped); + let _ = tokio::fs::remove_file(base).await; + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 48f41d0f..f3ce1f0e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -12,6 +12,7 @@ use blit_remote::desktop::{ msg_notification_update, msg_tray_menu, msg_tray_snapshot, msg_tray_update, parse_desktop_subscribe, parse_notification_event, parse_tray_event, }; +use blit_remote::events::{EVENTS, FEATURE_EVENTS}; #[cfg(target_os = "linux")] use blit_remote::media::{ ACTIVE_CAMERA, ACTIVE_MICROPHONE, ACTIVE_SCREENCAST, C2S_MEDIA_CONTROL, C2S_MEDIA_DATA, @@ -78,6 +79,7 @@ mod capacity_diagnostics; mod channel; #[cfg(target_os = "linux")] mod desktop_bus; +mod events; mod extension; pub mod extension_catalog; mod extension_jobs; @@ -3556,6 +3558,7 @@ struct VulkanVideoSurfaceState { enum QueuedMessage { Bulk(ConnectionBulk), Channel(channel::Delivery), + Event(Vec), } impl QueuedMessage { @@ -3563,6 +3566,7 @@ impl QueuedMessage { match self { Self::Bulk(packet) => packet.packet(), Self::Channel(delivery) => &delivery.packet, + Self::Event(packet) => packet, } } } @@ -6353,6 +6357,7 @@ impl Session { #[cfg(target_os = "linux")] let desktop_notify = event_notify.clone(); let handle = blit_compositor::spawn_compositor(verbose, event_notify, gpu_device); + events::blit_event!(events::EventId::CompositorStarted); // Ahead of the desktop bus and of every PTY, because both export // DISPLAY at spawn and an app that starts without it has no X at // all. The compositor is told whose connection to expect, so the @@ -8148,6 +8153,10 @@ impl Session { struct AppStateInner { config: Config, + #[cfg(not(test))] + events: Arc, + #[cfg(not(test))] + event_files: Arc, #[cfg(any(unix, windows))] process_server: process::Server, /// Opaque identifier shared by every connection to this server process. @@ -8174,12 +8183,37 @@ struct AppStateInner { type AppState = Arc; +impl AppStateInner { + fn event_recorder(&self) -> Arc { + #[cfg(not(test))] + { + self.events.clone() + } + #[cfg(test)] + { + events::global_arc() + } + } + + fn event_files(&self) -> Arc { + #[cfg(not(test))] + { + self.event_files.clone() + } + #[cfg(test)] + { + events::global_file_streams() + } + } +} + /// Enter the common shutdown path used by signals, C2S_QUIT, fd-channel EOF, /// and ordinary listener teardown. Admission is sealed before any await. async fn begin_server_shutdown(state: &AppState) { if !state.connections.seal_shutdown() { return; } + events::blit_event!(events::EventId::ServerStopping); // Attribute every attempt cancellation to shutdown before cancelling any // logical connection. This also seals extension restart admission. state.extensions.begin_shutdown().await; @@ -8219,6 +8253,16 @@ async fn begin_server_shutdown(state: &AppState) { state.shutdown_notify.notify_one(); } +async fn finish_server_shutdown(state: &AppState) { + #[cfg(any(unix, windows))] + state.process_server.shutdown().await; + state.extensions.shutdown().await; + state.connections.wait_empty().await; + events::blit_event!(events::EventId::CompositorStopped); + events::blit_event!(events::EventId::ServerStopped); + state.event_files().shutdown().await; +} + fn new_boot_generation() -> u64 { let mut bytes = [0; 8]; getrandom::fill(&mut bytes).expect("failed to generate boot generation"); @@ -9092,6 +9136,14 @@ fn refuse_create( status: u8, detail: &str, ) { + events::blit_event!( + events::EventId::PtyCreateError, + client_id, + nonce as u64, + status as u64, + want_status as u64, + 0 + ); if !want_status { return; } @@ -9399,6 +9451,7 @@ async fn evict_exited(state: &AppState) { let Some(pty) = sess.ptys.remove(&id) else { continue; }; + events::blit_event!(events::EventId::PtyEvict, 0, id as u64, 0, 0, 0); // Already exited by construction, so the fd and the child are gone; // this is only dropping the retained terminal state. drop(pty); @@ -9698,6 +9751,14 @@ async fn cleanup_pty_internal(pty_id: u16, generation: Option, state: &AppS pty.stop_deadline = None; pty::close_pty(&pty.handle); pty.exit_status = pty::collect_exit_status(&pty.handle); + events::blit_event!( + events::EventId::PtyExit, + 0, + pty_id as u64, + pty.exit_status as u64, + pty.exit_reason as u64, + 0 + ); pty.mark_dirty(); // A command still running when the shell dies never gets its `D` // marker; closing it here is what stops a waiter hanging until its @@ -9810,6 +9871,26 @@ fn try_send_update( } pub async fn run(config: Config) { + let event_startup = + events::EventStartupConfig::resolve(events::EventConfigOverrides::default()) + .unwrap_or_else(|error| panic!("invalid server event configuration: {error}")); + if let Err(error) = events::initialize(event_startup.clone()) { + if error != "event recorder is already initialized" { + panic!("invalid server event configuration: {error}"); + } + events::global() + .set_config(event_startup.config) + .unwrap_or_else(|error| panic!("invalid server event configuration: {error}")); + } + #[cfg(not(test))] + let event_recorder = events::global_arc(); + #[cfg(not(test))] + let event_files = Arc::new(events::FileStreamManager::with_startup_file( + event_recorder.clone(), + events::MAX_FILE_STREAMS, + event_startup.file, + )); + events::blit_event!(events::EventId::ServerStarting); // Embedders may not call `configure_deployment`; in that case freeze the // environment now, before any feature mask or service is constructed. let _ = ensure_deployment_settings(); @@ -9836,6 +9917,10 @@ pub async fn run(config: Config) { extension::ExtensionService::from_env(config.allow_persistent_extensions, &config.name); let state: AppState = Arc::new(AppStateInner { config, + #[cfg(not(test))] + events: event_recorder, + #[cfg(not(test))] + event_files, #[cfg(any(unix, windows))] process_server, boot_generation, @@ -9849,6 +9934,10 @@ pub async fn run(config: Config) { extension_jobs: extension_jobs::GlobalTracker::from_env(), extensions: extensions.clone(), }); + if let Err(error) = state.event_files().start_startup_file().await { + events::blit_event!(events::EventId::ServerError, 0, 0, 0, 0, 0); + eprintln!("failed to start BLIT_EVENTS_FILE: {error}"); + } extensions.restore(state.clone()).await; // Start the compositor eagerly so it is ready before any client @@ -9959,15 +10048,14 @@ pub async fn run(config: Config) { #[cfg(unix)] if let Some(channel_fd) = state.config.fd_channel { blit_sd_notify::notify_ready(state.config.verbose); + events::blit_event!(events::EventId::ServerStarted); let shutdown = state.shutdown_notify.clone(); tokio::select! { _ = ipc::run_fd_channel(channel_fd, state.clone()) => {} _ = shutdown.notified() => {} } begin_server_shutdown(&state).await; - state.extensions.shutdown().await; - state.process_server.shutdown().await; - state.connections.wait_empty().await; + finish_server_shutdown(&state).await; return; } @@ -9983,6 +10071,7 @@ pub async fn run(config: Config) { let mut listener = IpcListener::bind(&state.config.ipc_path, state.config.verbose).await; blit_sd_notify::notify_ready(state.config.verbose); + events::blit_event!(events::EventId::ServerStarted); let shutdown = state.shutdown_notify.clone(); loop { @@ -10000,10 +10089,7 @@ pub async fn run(config: Config) { spawn_network_client(stream, state.clone()); } begin_server_shutdown(&state).await; - #[cfg(any(unix, windows))] - state.process_server.shutdown().await; - state.extensions.shutdown().await; - state.connections.wait_empty().await; + finish_server_shutdown(&state).await; } /// Minimum interval between blanket RequestFrame rounds. Keeps video @@ -10152,6 +10238,14 @@ async fn tick(state: &AppState) -> TickOutcome { width, height, } => { + events::blit_event!( + events::EventId::SurfaceCreated, + 0, + surface_id as u64, + width as u64, + height as u64, + parent_id as u64 + ); broadcast.push(msg_surface_created( surface_id, parent_id, width, height, &title, &app_id, )); @@ -10180,6 +10274,14 @@ async fn tick(state: &AppState) -> TickOutcome { invalidate_client_encoders.push(surface_id); } CompositorEvent::SurfaceDestroyed { surface_id } => { + events::blit_event!( + events::EventId::SurfaceDestroyed, + 0, + surface_id as u64, + 0, + 0, + 0 + ); #[cfg(target_os = "linux")] { let retired = retire_screencast_surface(cs, surface_id); @@ -10213,6 +10315,14 @@ async fn tick(state: &AppState) -> TickOutcome { timestamp_sub_us, encoder_skip, } => { + events::blit_event!( + events::EventId::SurfaceFrameQueued, + 0, + surface_id as u64, + width as u64, + height as u64, + 0 + ); surface_commit_count += 1; #[cfg(target_os = "linux")] let screencast_frame = { @@ -13411,6 +13521,14 @@ async fn tick(state: &AppState) -> TickOutcome { }; match input { PtyInput::Data(data) => { + events::blit_event!( + events::EventId::PtyDrain, + 0, + id as u64, + data.len() as u64, + budget as u64, + 0 + ); budget = budget.saturating_sub(data.len()); if let Some(msg) = feed_pty_chunk(pty, id, &data) { cwd_msgs.push(msg); @@ -13432,6 +13550,7 @@ async fn tick(state: &AppState) -> TickOutcome { } } PtyInput::Eof => { + events::blit_event!(events::EventId::PtyExit, 0, id as u64, 0, 0, 0); let child_exited = pty.exit_drain_deadline.is_some() || pty::poll_child_exited(&pty.handle); if child_exited && pty.exit_drain_deadline.is_none() { @@ -17690,6 +17809,11 @@ async fn handle_client_registered(); let (raw_channel_tx, mut channel_rx) = mpsc::unbounded_channel::(); + // Event traffic is diagnostic and must never amplify an unbounded ordinary + // outbox. The writer polls this bounded lane only after user-facing lanes. + let (event_tx, mut event_rx) = mpsc::channel::>(32); + let writer_connection = Arc::new(AtomicU64::new(0)); + let sender_connection = writer_connection.clone(); let outbox_frame_counter = Arc::new(AtomicUsize::new(0)); let outbox_byte_counter = Arc::new(AtomicUsize::new(0)); let extension_outbox_config = extension_outbox_config(); @@ -17978,6 +18102,7 @@ async fn handle_client_registered channel_open = false, } } + packet = event_rx.recv() => { + break packet.map(QueuedMessage::Event); + } } } } => msg, @@ -18002,6 +18130,15 @@ async fn handle_client_registered { let packet = m.packet(); let bytes = packet.len(); + let connection = sender_connection.load(Ordering::Relaxed); + events::blit_event!( + events::EventId::WriterDequeue, + connection, + packet.first().copied().unwrap_or_default() as u64, + bytes as u64, + 0, + 0 + ); let ordinary = matches!( &m, QueuedMessage::Bulk(ConnectionBulk::Ordinary(_)) @@ -18021,9 +18158,17 @@ async fn handle_client_registered None, - QueuedMessage::Channel(_) => None, + QueuedMessage::Channel(_) | QueuedMessage::Event(_) => None, }; let write_start = Instant::now(); + events::blit_event!( + events::EventId::WriterWriteBegin, + connection, + packet.first().copied().unwrap_or_default() as u64, + bytes as u64, + 0, + 0 + ); let wrote = write_frame_interleaved( &mut writer, packet, @@ -18045,6 +18190,14 @@ async fn handle_client_registered 30 { + events::blit_event!( + events::EventId::WriterBackpressure, + connection, + packet.first().copied().unwrap_or_default() as u64, + bytes as u64, + write_elapsed.as_micros().min(u64::MAX as u128) as u64, + 0 + ); eprintln!( "[sender] slow write: bytes={bytes} elapsed={}ms wrote={}", write_elapsed.as_millis(), @@ -18052,11 +18205,27 @@ async fn handle_client_registered break, } @@ -18235,6 +18404,7 @@ async fn handle_client_registered (None, None), }; + events::blit_event!( + events::EventId::ProcessSpawn, + client_id, + data[0] as u64, + data.len() as u64, + 0, + 0 + ); processes.spawn(&data, pty_cwd.as_deref(), session_env); } else { processes.handle(&data); @@ -19217,6 +19449,14 @@ async fn handle_client_registered= 3 { let pid = u16::from_le_bytes([data[1], data[2]]); + events::blit_event!( + events::EventId::PtyInput, + client_id, + pid as u64, + data.len().saturating_sub(3) as u64, + 0, + 0 + ); let mut need_nudge = false; { let mut sess = state.session.lock().await; @@ -19469,7 +19709,31 @@ async fn handle_client_registered = None; - if let Some(pty) = pty::spawn_pty( + events::blit_event!( + events::EventId::PtyCreateSpawnBegin, + client_id, + data[0] as u64, + id as u64, + rows as u64, + cols as u64 + ); + let spawned_pty = pty::spawn_pty( &config.shell, &config.shell_flags, rows, @@ -20095,12 +20391,39 @@ async fn handle_client_registered { @@ -20151,10 +20482,26 @@ async fn handle_client_registered = None; - if let Some(pty) = pty::spawn_pty( + events::blit_event!( + events::EventId::PtyCreateSpawnBegin, + client_id, + data[0] as u64, + id as u64, + rows as u64, + cols as u64 + ); + let spawned_pty = pty::spawn_pty( &config.shell, &config.shell_flags, rows, @@ -20193,7 +20548,26 @@ async fn handle_client_registered { @@ -20253,10 +20643,26 @@ async fn handle_client_registered = None; - if let Some(pty) = pty::spawn_pty( + events::blit_event!( + events::EventId::PtyCreateSpawnBegin, + client_id, + data[0] as u64, + id as u64, + rows as u64, + cols as u64 + ); + let spawned_pty = pty::spawn_pty( &config.shell, &config.shell_flags, rows, @@ -20293,12 +20707,39 @@ async fn handle_client_registered { @@ -20403,7 +20852,15 @@ async fn handle_client_registered = None; - if let Some(pty) = pty::spawn_pty( + events::blit_event!( + events::EventId::PtyCreateSpawnBegin, + client_id, + data[0] as u64, + id as u64, + rows as u64, + cols as u64 + ); + let spawned_pty = pty::spawn_pty( &config.shell, &config.shell_flags, rows, @@ -20428,7 +20885,26 @@ async fn handle_client_registered {} + _ => { + events::blit_event!( + events::EventId::RawRequestReject, + client_id, + data[0] as u64, + data.len() as u64, + 0, + 0 + ); + } } drop(sess); if let Some(line) = deferred_verbose_log { @@ -21738,6 +22239,9 @@ async fn handle_client_registered STATUS_INVALID, _ => STATUS_OTHER, }; + blit_event!( + EventId::ProcessResult, + 0, + req.process_id as u64, + status as u64, + 0, + 0 + ); complete_spawn_failure(&pending, req.nonce, status, &error.to_string()); return; } @@ -1079,6 +1096,14 @@ impl Manager { #[cfg(windows)] job, } = spawned; + blit_event!( + EventId::ProcessResult, + 0, + req.process_id as u64, + STATUS_OK as u64, + pid as u64, + 0 + ); let stdin = child.stdin.take().expect("piped stdin"); let stdout = (!merged).then(|| child.stdout.take().expect("piped stdout")); let stderr = (!merged).then(|| child.stderr.take().expect("piped stderr")); @@ -3030,6 +3055,14 @@ fn try_queue_terminal(record: &Arc) { }; record.terminal_notify.notify_waiters(); let (bindings, final_record) = terminal; + blit_event!( + EventId::ProcessExit, + 0, + record.generation, + final_record.reason as u64, + final_record.code as u64, + final_record.pid as u64 + ); if bindings.is_empty() { finish_terminal(record.clone(), final_record); return; diff --git a/docs/design/events.md b/docs/design/events.md new file mode 100644 index 00000000..781a4bda --- /dev/null +++ b/docs/design/events.md @@ -0,0 +1,196 @@ +--- +title: Structured events protocol +--- + +# `blit.events.v1` + +`blit.events.v1` is the bounded remote and file representation for structured +server events. All integers are little-endian. + +## Discovery and envelope + +A server advertises feature bit 31 (`FEATURE_EVENTS`). Both directions use the +direction-local opcode `0x96` and this eight-byte envelope: + +```text +[0x96][version:1 = 1][kind:1][flags:1 = 0][request_id:4][body...] +``` + +`request_id = 0` is used for unsolicited stream data; requests carry a +caller-selected id. A receiver rejects unknown versions, kinds, and envelope +flags. Once the full envelope has arrived, an invalid request can always be +answered with the same request id using `S2C_STATUS`; an incomplete envelope +cannot be correlated. + +## Event record + +Remote dumps, live data, and files use the same 64-byte record: + +```text +[sequence:8] +[monotonic_ns:8] +[event_id:4][flags:2][source:1][schema:1] +[connection:8][subject:8] +[arg0:8][arg1:8][arg2:8] +``` + +`sequence` is the monotonic ring sequence. `monotonic_ns` uses the server's +monotonic clock and is meaningful only within one boot. Event catalogs define +the remaining fields. Unknown record flags are preserved because their meaning +is selected by `(event_id, schema)` rather than by this transport. + +## Configuration and dump + +The activation mask is exactly 16 bytes. Bit `n` controls event id `n` for ids +0 through 127. Ring size is in records and must be in `1..=1,048,576`. + +Stable named ids are grouped into these ranges: + +| Range | Family | Named ids | +| ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| 0–7 | server | starting, started, stopping, stopped, error | +| 8–15 | client | connected, ready, disconnecting, disconnected, error | +| 16–23 | raw request | read, dispatch, done, reject | +| 24–31 | writer | dequeue, write-begin, write-end, error, backpressure | +| 32–55 | PTY | create lifecycle, read/queue/drain/parse/frame/input/resize/exit/evict/I/O error | +| 56–63 | process | request, spawn, result, I/O, exit, error | +| 64–71 | compositor/surface | compositor lifecycle and surface create/destroy/frame/error | +| 72–103 | protocol | core, PTY, process, compositor, surface, input, clipboard, filesystem, network, KV, browser, audio, events, integration, error | +| 104–111 | task | spawned, completed, cancelled, failed | +| 112–127 | recorder | config changed/error, ring dropped/overwritten, stream gap/error | + +The complete stable `(id, name)` table is `blit_remote::events::EVENT_NAMES`. +Names use lowercase kebab case. Unlisted bits are reserved but remain visible +and round-trip in activation masks. + +The server allocates a 1 MiB ring by default. Its default activation enables +low-rate server, client, PTY-create, PTY/process exit, compositor/surface +lifecycle, and error/refusal events; request, writer, PTY I/O, process I/O, +frame, and protocol-family events are opt-in. Startup configuration is: + +```text +BLIT_EVENTS_BYTES=1MiB +BLIT_EVENTS=default|all|none|family,event,+event,-event +BLIT_EVENTS_FILE=/path/to/capture.events +``` + +`BLIT_EVENTS_BYTES` must be a multiple of the 64-byte record size. Runtime +`CONFIG_SET` can change both capacity and the activation bitset. Resizing keeps +the newest complete records that fit; a producer that collides with resize or +an in-progress overwrite consumes a sequence and is therefore visible as a +gap rather than silently disappearing. `CONFIG_SET_IF` performs the same change +only if both current fields still match the expected configuration. A mismatch +returns `STATUS_CONFLICT` and the current configuration without changing it. + +Client-to-server kinds: + +```text +1 CONFIG_GET [] +2 CONFIG_SET [ring_size:4][activation:16] +3 DUMP [from_sequence:8][limit:4] +8 CONFIG_SET_IF [expected_ring_size:4][expected_activation:16] + [ring_size:4][activation:16] +``` + +Server-to-client kinds: + +```text +0 STATUS [request_kind:1][status:1] +1 CONFIG [status:1][ring_size:4][activation:16] +2 DUMP [status:1][first_sequence:8][next_sequence:8] + [count:4][record:64]... +``` + +A dump limit is `1..=65,536`. `first_sequence` reports the first returned +sequence after any eviction clamp. `next_sequence` is the cursor for the next +request. Status values come from the common protocol status registry. + +## Client live streams + +Client-to-server kinds: + +```text +4 STREAM_START [stream_id:4][from_sequence:8][flags:1] +5 STREAM_STOP [stream_id:4] +``` + +`STREAM_FOLLOW` (flags bit 0) keeps the stream open after replay reaches the +live edge. Other bits are invalid. + +Server-to-client kinds: + +```text +3 STREAM_STATUS [status:1][stream_id:4][next_sequence:8] +4 STREAM_DATA [stream_id:4][server_monotonic_ns:8] + [count:4][record:64]... +``` + +`STREAM_STATUS` is correlated to start or stop. Unsolicited statuses use request +id zero: `STATUS_BUDGET` reports a gap, while the first `STATUS_OK` marks the +transition from replay to the live edge. They cannot be mistaken for a second +reply to the completed start request. `STREAM_DATA` is also unsolicited, so its +envelope request id is zero. `server_monotonic_ns` is sampled from the recorder +clock when the packet is built, allowing a consumer to age replayed records +without assuming immediate delivery. One data packet carries at most 65,536 +records. + +## Server-side file streams + +These streams make the server write canonical event files without relaying all +records through a CLI or guest. + +Client-to-server kinds: + +```text +6 FILE_START [stream_id:4][flags:1][path_len:2][path...] +7 FILE_STOP [stream_id:4] +``` + +Paths are nonempty, NUL-free UTF-8 of at most 4096 bytes. `FILE_APPEND` is flags +bit 0 and `FILE_SYNC` is bit 1. Other bits are invalid. + +Server-to-client kind: + +```text +5 FILE_STATUS [status:1][stream_id:4][records_written:8][bytes_written:8] + [detail_len:2][detail...] +``` + +Status is correlated to start or stop. Detail is UTF-8 of at most 4096 bytes. + +## Canonical event file + +A canonical file begins with this 32-byte header, followed immediately by +64-byte event records: + +```text +["blit.events.v1\0\0":16] +[version:1 = 1][flags:1 = 0] +[header_size:2 = 32][record_size:2 = 64] +[reserved:10 = 0] +``` + +There is exactly one valid v1 header encoding. A reader rejects nonzero flags or +reserved bytes and mismatched sizes rather than guessing a layout. + +## CLI mapping + +`blit events config [--json]` sends `CONFIG_GET`. `blit events config set` +sends `CONFIG_SET`; when only one of `--bytes` and `--active` is present, it +first reads the current configuration and preserves the omitted field. The +CLI presents ring capacity in bytes even though the wire stores record count, +and requires the byte value to be a multiple of 64. Activation input is either +32 hexadecimal digits (the 16 wire bytes in display order) or comma-separated +names, numeric ids, and family selectors. + +`blit events dump` emits one canonical header followed by the records from one +`DUMP` reply. `blit events stream` uses a random nonzero stream id, maps +`oldest` to sequence zero and `now` to `u64::MAX`, writes one canonical header, +and appends each `STREAM_DATA` record unchanged. It sends `STREAM_STOP` on +Ctrl-C or a broken output pipe. Every solicited reply is accepted only when its +request id and operation match the request; unsolicited stream data must have +the protocol-mandated zero request id. + +`blit events file start` and `stop` map directly to the server-side file stream +messages. Paths name the server filesystem, not the client filesystem. The CLI +prints the stream id and counters returned by `FILE_STATUS`. diff --git a/docs/protocol.md b/docs/protocol.md index 03ec37da..5c0d7b00 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -24,6 +24,11 @@ Maximum frame size: **16 MiB**. Every message begins with a **1-byte opcode**. All multi-byte fields are little-endian. Fields are tightly packed with no padding or alignment. PTY identifiers are 2-byte unsigned integers. +Opcode `0x96` in both directions is the internally versioned `blit.events.v1` +family, advertised by feature bit 31. Its config, dump, client-stream, +server-file-stream, event-record, and file-header layouts are specified in +[`design/events.md`](design/events.md). The global protocol version remains 1. + Any per-request reply guarantee is conditional on the logical connection remaining live through that reply. A transport failure or a documented fatal framing, protocol, or endpoint-resource violation closes the connection and