use std::time::{SystemTime, UNIX_EPOCH};
use shep_client::{
Lagged,
shep_core::protocol::{BusEvent, ProcessInfo},
};
use crate::{
config::Config,
names::Names,
stream::{
Sink, SinkError,
buffer::{Buffer, Line},
pack::{self, Chunk},
},
};
const BUS_NOTICE_NAME: &str = "shep-discord";
fn dropped_notice(count: u64) -> String {
format!("{count} lines dropped: this dog fell behind the shepherd's bus")
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
struct Sided {
channel: u64,
buffer: Buffer,
}
pub struct State<'a, S: Sink> {
own_id: Option<u32>,
names: Names,
out: Option<Sided>,
err: Option<Sided>,
coalesce_ms: u64,
sink: &'a S,
}
impl<'a, S: Sink> State<'a, S> {
#[must_use]
#[allow(
dead_code,
reason = "called only by this module's own tests; run builds a State through from_config"
)]
pub fn new(own_id: Option<u32>, sink: &'a S) -> Self {
Self::with_channels(own_id, Some(0), Some(0), sink)
}
#[must_use]
#[allow(
dead_code,
reason = "called only by this module's own tests; run builds a State through from_config"
)]
pub fn with_channels(
own_id: Option<u32>,
log_channel: Option<u64>,
err_channel: Option<u64>,
sink: &'a S,
) -> Self {
Self::from_parts(
own_id,
Names::new(),
log_channel,
err_channel,
crate::config::DEFAULT_BUFFER_LINES,
crate::config::DEFAULT_COALESCE_MS,
sink,
)
}
#[must_use]
pub fn from_config(own_id: Option<u32>, names: Names, config: &Config, sink: &'a S) -> Self {
Self::from_parts(
own_id,
names,
config.log_channel,
config.err_channel,
config.buffer_lines,
config.coalesce.as_millis(),
sink,
)
}
fn from_parts(
own_id: Option<u32>,
names: Names,
log_channel: Option<u64>,
err_channel: Option<u64>,
buffer_lines: usize,
coalesce_ms: u64,
sink: &'a S,
) -> Self {
Self {
own_id,
names,
out: log_channel.map(|channel| Sided {
channel,
buffer: Buffer::new(buffer_lines),
}),
err: err_channel.map(|channel| Sided {
channel,
buffer: Buffer::new(buffer_lines),
}),
coalesce_ms,
sink,
}
}
pub fn refresh_names(&mut self, roll: &[ProcessInfo]) {
self.names.refresh(roll);
}
pub fn on_event(&mut self, event: BusEvent) {
match event {
BusEvent::LogOut { id, line } => self.push(Side::Out, id, line),
BusEvent::LogErr { id, line } => self.push(Side::Err, id, line),
BusEvent::Dropped { count } => self.push_notice(count),
_ => {}
}
}
pub fn on_lagged(&mut self, lagged: Lagged) {
self.push_notice(lagged.count);
}
fn push(&mut self, side: Side, id: u32, line: String) {
if self.own_id == Some(id) {
return;
}
let name = self.names.get(id);
let Some(sided) = self.side_mut(side) else {
return;
};
sided.buffer.push(Line {
at_ms: now_ms(),
name,
text: pack::strip_ansi(&line),
});
}
fn push_notice(&mut self, count: u64) {
let sided = if self.out.is_some() {
self.out.as_mut()
} else {
self.err.as_mut()
};
let Some(sided) = sided else {
return;
};
sided.buffer.push(dropped_line(count));
}
fn side_mut(&mut self, side: Side) -> Option<&mut Sided> {
match side {
Side::Out => self.out.as_mut(),
Side::Err => self.err.as_mut(),
}
}
pub async fn flush(&mut self) {
Self::flush_side(&mut self.out, self.coalesce_ms, self.sink).await;
Self::flush_side(&mut self.err, self.coalesce_ms, self.sink).await;
}
async fn flush_side(sided: &mut Option<Sided>, coalesce_ms: u64, sink: &S) {
let Some(sided) = sided else { return };
let dropped = sided.buffer.take_dropped();
if dropped > 0 {
sided.buffer.push(dropped_line(dropped));
}
for group in sided.buffer.drain(coalesce_ms) {
let chunks = pack::chunks(&group);
for message in pack::into_messages(chunks) {
send_or_drop(sink, sided.channel, message).await;
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Side {
Out,
Err,
}
fn dropped_line(count: u64) -> Line {
Line {
at_ms: now_ms(),
name: BUS_NOTICE_NAME.to_owned(),
text: dropped_notice(count),
}
}
fn message_characters(message: &[Chunk]) -> usize {
message
.iter()
.map(|chunk| chunk.title.chars().count() + chunk.description.chars().count())
.sum()
}
fn refused_message(channel: u64, characters: usize, err: SinkError) -> String {
format!(
"shep-discord: channel {channel} refused a {characters} character message, dropping \
it: {err}"
)
}
async fn send_or_drop<S: Sink>(sink: &S, channel: u64, message: Vec<Chunk>) {
let characters = message_characters(&message);
if let Err(err) = sink.send(channel, message).await {
eprintln!("{}", refused_message(channel, characters, err));
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
fn own_id(id: u32) -> Option<u32> {
Some(id)
}
struct Recording {
sent: Mutex<Vec<(u64, Chunk)>>,
attempts: Mutex<usize>,
reject: Option<SinkError>,
}
impl Recording {
fn new() -> Self {
Self {
sent: Mutex::new(Vec::new()),
attempts: Mutex::new(0),
reject: None,
}
}
fn rejecting(error: SinkError) -> Self {
Self {
reject: Some(error),
..Self::new()
}
}
fn sent(&self) -> Vec<Chunk> {
self.sent
.lock()
.expect("not poisoned")
.iter()
.map(|(_, chunk)| chunk.clone())
.collect()
}
fn channel_of(&self, text: &str) -> Option<u64> {
self.sent
.lock()
.expect("not poisoned")
.iter()
.find(|(_, chunk)| chunk.description.contains(text))
.map(|(channel, _)| *channel)
}
fn attempts(&self) -> usize {
*self.attempts.lock().expect("not poisoned")
}
}
impl Sink for Recording {
async fn send(&self, channel: u64, chunks: Vec<Chunk>) -> Result<(), SinkError> {
*self.attempts.lock().expect("not poisoned") += 1;
if let Some(error) = self.reject {
return Err(error);
}
let mut sent = self.sent.lock().expect("not poisoned");
sent.extend(chunks.into_iter().map(|chunk| (channel, chunk)));
Ok(())
}
}
#[tokio::test]
async fn the_dog_never_streams_its_own_output() {
let sink = Recording::new();
let mut state = State::new(own_id(9), &sink);
state.on_event(BusEvent::LogOut {
id: 9,
line: "my own line".into(),
});
state.on_event(BusEvent::LogOut {
id: 1,
line: "web line".into(),
});
state.flush().await;
let sent = sink.sent();
assert_eq!(sent.len(), 1);
assert!(sent[0].description.contains("web line"));
assert!(!sent[0].description.contains("my own line"));
}
#[tokio::test]
async fn an_unadopted_dog_streams_everything_unfiltered() {
let sink = Recording::new();
let mut state = State::new(None, &sink);
state.on_event(BusEvent::LogOut {
id: u32::MAX,
line: "web line".into(),
});
state.flush().await;
let sent = sink.sent();
assert_eq!(sent.len(), 1);
assert!(sent[0].description.contains("web line"));
}
#[tokio::test]
async fn a_dropped_run_is_reported_rather_than_hidden() {
let sink = Recording::new();
let mut state = State::new(own_id(9), &sink);
state.on_event(BusEvent::Dropped { count: 37 });
state.flush().await;
let sent = sink.sent();
assert_eq!(sent[0].description, dropped_notice(37));
}
#[tokio::test]
async fn a_rejected_batch_is_dropped_not_retried() {
let sink = Recording::rejecting(SinkError::BadRequest);
let mut state = State::new(own_id(9), &sink);
state.on_event(BusEvent::LogOut {
id: 1,
line: "web line".into(),
});
state.flush().await;
state.flush().await;
assert_eq!(
sink.attempts(),
1,
"the second flush must not resend the refused batch"
);
}
#[tokio::test]
async fn stderr_and_stdout_go_to_their_own_channels() {
let sink = Recording::new();
let mut state = State::with_channels(own_id(9), Some(10), Some(20), &sink);
state.on_event(BusEvent::LogOut {
id: 1,
line: "out".into(),
});
state.on_event(BusEvent::LogErr {
id: 1,
line: "err".into(),
});
state.flush().await;
assert_eq!(sink.channel_of("out"), Some(10));
assert_eq!(sink.channel_of("err"), Some(20));
}
#[tokio::test]
async fn an_unconfigured_channel_never_buffers_a_line() {
let sink = Recording::new();
let mut state = State::with_channels(own_id(9), Some(10), None, &sink);
state.on_event(BusEvent::LogErr {
id: 1,
line: "err".into(),
});
state.flush().await;
assert!(sink.sent().is_empty(), "no err_channel means no err buffer");
}
#[tokio::test]
async fn a_lagged_subscription_is_reported_the_same_way() {
let sink = Recording::new();
let mut state = State::new(own_id(9), &sink);
state.on_lagged(Lagged { count: 5 });
state.flush().await;
let sent = sink.sent();
assert_eq!(sent[0].description, dropped_notice(5));
}
#[test]
fn nothing_printed_for_a_person_carries_a_dash() {
crate::test_support::assert_no_dashes(&dropped_notice(5));
crate::test_support::assert_no_dashes(&refused_message(1, 2, SinkError::BadRequest));
}
}