use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::io::AsyncWriteExt as _;
use super::envelope::HarnessEvent;
use crate::uds::UdsRpcError;
pub const DEFAULT_PUSH_BUFFER_CAPACITY: usize = 4096;
pub const DEFAULT_PUSH_BUFFER_BYTES: u64 = 8 * 1024 * 1024;
pub const DEFAULT_PUSH_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug)]
pub struct PushFlushOutcome {
pub sent: usize,
pub error: Option<UdsRpcError>,
}
struct QueuedFrame {
event: HarnessEvent,
bytes: u64,
}
struct PushBuffer {
frames: VecDeque<QueuedFrame>,
total_bytes: u64,
}
impl PushBuffer {
fn new(capacity: usize) -> Self {
Self {
frames: VecDeque::with_capacity(capacity.min(64)),
total_bytes: 0,
}
}
fn len(&self) -> usize {
self.frames.len()
}
fn is_empty(&self) -> bool {
self.frames.is_empty()
}
fn push_back(&mut self, frame: QueuedFrame) {
self.total_bytes = self.total_bytes.saturating_add(frame.bytes);
self.frames.push_back(frame);
}
fn push_front(&mut self, frame: QueuedFrame) {
self.total_bytes = self.total_bytes.saturating_add(frame.bytes);
self.frames.push_front(frame);
}
fn pop_front(&mut self) -> Option<QueuedFrame> {
let frame = self.frames.pop_front()?;
self.total_bytes = self.total_bytes.saturating_sub(frame.bytes);
Some(frame)
}
}
fn frame_byte_len(event: &HarnessEvent) -> u64 {
crate::uds::encode_frame(event)
.map(|frame| frame.len() as u64)
.unwrap_or(0)
}
pub struct PushClient {
socket_path: PathBuf,
capacity: usize,
max_bytes: u64,
timeout: Duration,
buffer: Mutex<PushBuffer>,
dropped: AtomicU64,
}
impl PushClient {
pub fn new(socket_path: impl Into<PathBuf>) -> Self {
Self::with_limits(
socket_path,
DEFAULT_PUSH_BUFFER_CAPACITY,
DEFAULT_PUSH_BUFFER_BYTES,
)
}
pub fn with_capacity(socket_path: impl Into<PathBuf>, capacity: usize) -> Self {
Self::with_limits(socket_path, capacity, DEFAULT_PUSH_BUFFER_BYTES)
}
pub fn with_limits(socket_path: impl Into<PathBuf>, capacity: usize, max_bytes: u64) -> Self {
let capacity = capacity.max(1);
Self {
socket_path: socket_path.into(),
capacity,
max_bytes: max_bytes.max(1),
timeout: DEFAULT_PUSH_TIMEOUT,
buffer: Mutex::new(PushBuffer::new(capacity)),
dropped: AtomicU64::new(0),
}
}
pub fn send(&self, event: HarnessEvent) {
let bytes = frame_byte_len(&event);
let mut buffer = self.lock_buffer();
buffer.push_back(QueuedFrame { event, bytes });
while buffer.len() > self.capacity
|| (buffer.total_bytes > self.max_bytes && buffer.len() > 1)
{
if buffer.pop_front().is_none() {
break;
}
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
pub async fn flush(&self) -> PushFlushOutcome {
if self.lock_buffer().is_empty() {
return PushFlushOutcome {
sent: 0,
error: None,
};
}
let mut stream = match tokio::time::timeout(
self.timeout,
crate::uds::connect_hardened(&self.socket_path),
)
.await
{
Ok(Ok(stream)) => stream,
Ok(Err(source)) => {
return PushFlushOutcome {
sent: 0,
error: Some(UdsRpcError::Dial {
path: self.socket_path.clone(),
source,
}),
};
}
Err(_) => {
return PushFlushOutcome {
sent: 0,
error: Some(UdsRpcError::Timeout {
path: self.socket_path.clone(),
timeout: self.timeout,
}),
};
}
};
let mut sent = 0usize;
loop {
let Some(frame) = self.lock_buffer().pop_front() else {
break;
};
match tokio::time::timeout(
self.timeout,
crate::uds::write_frame(&mut stream, &frame.event),
)
.await
{
Ok(Ok(())) => sent += 1,
Ok(Err(source)) => {
self.lock_buffer().push_front(frame);
return PushFlushOutcome {
sent,
error: Some(UdsRpcError::Write {
path: self.socket_path.clone(),
source,
}),
};
}
Err(_) => {
self.lock_buffer().push_front(frame);
return PushFlushOutcome {
sent,
error: Some(UdsRpcError::Timeout {
path: self.socket_path.clone(),
timeout: self.timeout,
}),
};
}
}
}
let _ = stream.shutdown().await;
PushFlushOutcome { sent, error: None }
}
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
pub fn buffered_len(&self) -> usize {
self.lock_buffer().len()
}
fn lock_buffer(&self) -> std::sync::MutexGuard<'_, PushBuffer> {
self.buffer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use tokio::io::AsyncReadExt;
use tokio::net::UnixListener;
use tracing_subscriber::layer::SubscriberExt;
use super::*;
use crate::control_bus::{ActionMeta, Actor, EventId, HarnessPayload, HarnessSource};
use crate::log_buffer::{LogBuffer, LogBufferLayer};
fn sample_event() -> HarnessEvent {
HarnessEvent {
source: HarnessSource::Mpm,
session: Some("s1".into()),
seq: 0,
at: chrono::Utc::now(),
payload: HarnessPayload::Action(crate::control_bus::ActionEvent::Session {
meta: ActionMeta {
id: EventId::new(),
at: chrono::Utc::now(),
source: HarnessSource::Mpm,
session: Some("s1".into()),
parent_id: None,
actor: Actor::System,
objects: Vec::new(),
schema_version: 1,
},
phase: crate::control_bus::SessionPhase::Started,
}),
id: EventId::new(),
parent_id: None,
}
}
#[tokio::test]
async fn push_client_buffers_when_the_socket_is_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("absent.sock");
let client = PushClient::new(&sock);
client.send(sample_event());
assert_eq!(client.buffered_len(), 1, "the frame stays buffered");
let outcome = client.flush().await;
assert_eq!(outcome.sent, 0, "nothing could be delivered");
assert!(outcome.error.is_some(), "flush reports why it stopped");
assert_eq!(
client.buffered_len(),
1,
"a failed flush must not lose the frame"
);
assert_eq!(
client.dropped(),
0,
"buffering under capacity drops nothing"
);
}
#[tokio::test]
async fn push_client_flushes_once_the_socket_appears() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("console.sock");
let client = PushClient::new(&sock);
client.send(sample_event());
client.send(sample_event());
assert_eq!(client.buffered_len(), 2);
let listener: UnixListener = crate::uds::bind_hardened(&sock).expect("bind");
let served = tokio::spawn(async move {
let (mut conn, _) = listener.accept().await.expect("accept");
let mut buf = Vec::new();
conn.read_to_end(&mut buf).await.expect("drain");
buf
});
let outcome = client.flush().await;
assert_eq!(outcome.sent, 2, "both buffered frames were delivered");
assert!(outcome.error.is_none());
assert_eq!(client.buffered_len(), 0, "the buffer is drained");
let buf = served.await.expect("join");
let text = String::from_utf8(buf).expect("utf8");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
2,
"one newline-terminated JSON frame per event, over the same connection: {text}"
);
for line in lines {
assert!(
line.contains("\"domain\":\"action\""),
"the pushed frame carries the Action payload: {line}"
);
}
}
#[tokio::test]
async fn flush_dials_exactly_once_for_the_whole_drain() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("console.sock");
let client = PushClient::new(&sock);
const FRAME_COUNT: usize = 100;
for _ in 0..FRAME_COUNT {
client.send(sample_event());
}
assert_eq!(client.buffered_len(), FRAME_COUNT);
let listener: UnixListener = crate::uds::bind_hardened(&sock).expect("bind");
let connections = Arc::new(AtomicUsize::new(0));
let accept_connections = connections.clone();
let served = tokio::spawn(async move {
let mut lines = Vec::new();
while let Ok(Ok((mut conn, _))) =
tokio::time::timeout(Duration::from_millis(500), listener.accept()).await
{
accept_connections.fetch_add(1, Ordering::SeqCst);
let mut buf = Vec::new();
conn.read_to_end(&mut buf).await.expect("drain");
let text = String::from_utf8(buf).expect("utf8");
lines.extend(text.lines().map(str::to_string));
}
lines
});
let outcome = client.flush().await;
assert_eq!(
outcome.sent, FRAME_COUNT,
"every buffered frame was delivered"
);
assert!(outcome.error.is_none());
let lines = served.await.expect("join");
assert_eq!(
connections.load(Ordering::SeqCst),
1,
"flush must dial exactly one connection for the whole drain"
);
assert_eq!(
lines.len(),
FRAME_COUNT,
"one line per buffered frame, all over that one connection"
);
}
#[tokio::test]
async fn push_client_drops_the_oldest_frame_beyond_capacity() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("absent.sock");
let client = PushClient::with_capacity(&sock, 2);
for _ in 0..5 {
client.send(sample_event());
}
assert_eq!(client.buffered_len(), 2, "never grows past capacity");
assert_eq!(client.dropped(), 3, "the three oldest overflow frames");
}
#[tokio::test]
async fn oversized_events_evict_earlier_frames_by_bytes_before_the_count_cap() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("absent.sock");
let one_frame_bytes = frame_byte_len(&sample_event());
let byte_budget = one_frame_bytes.saturating_mul(2) + one_frame_bytes / 2;
let client = PushClient::with_limits(&sock, 1000, byte_budget);
for _ in 0..5 {
client.send(sample_event());
}
assert!(
client.buffered_len() < 5,
"the byte budget must evict frames the 1000-frame count cap alone would not: \
buffered_len={}",
client.buffered_len()
);
assert!(
client.dropped() > 0,
"byte-budget evictions must be counted as drops, same as count-cap evictions"
);
}
#[test]
fn ten_thousand_sends_with_no_socket_complete_under_one_second() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("never-there.sock");
let client = PushClient::new(&sock);
let start = std::time::Instant::now();
for _ in 0..10_000 {
client.send(sample_event());
}
let elapsed = start.elapsed();
assert_eq!(
client.buffered_len(),
DEFAULT_PUSH_BUFFER_CAPACITY,
"10,000 sends against the default capacity must leave the buffer \
exactly full, not partially drained or short-circuited"
);
assert_eq!(
client.dropped(),
10_000 - DEFAULT_PUSH_BUFFER_CAPACITY as u64,
"every eviction beyond the default capacity must be counted"
);
assert!(
elapsed < Duration::from_secs(10),
"10,000 no-socket sends took {elapsed:?}, expected under 10s"
);
}
#[test]
fn thousand_sends_emit_no_tracing_lines() {
let buffer = LogBuffer::new(16);
let subscriber = tracing_subscriber::registry().with(LogBufferLayer::new(buffer.clone()));
tracing::subscriber::with_default(subscriber, || {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("silent.sock");
let client = PushClient::new(&sock);
for _ in 0..1_000 {
client.send(sample_event());
}
});
let lines = buffer.tail(16);
assert!(
lines.is_empty(),
"send() must not emit any tracing lines, got: {lines:?}"
);
}
}