use std::collections::{HashMap, HashSet};
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
use futures_util::FutureExt;
use serde::Serialize;
use shep_client::{Client, EventStream, Lagged};
use shep_core::protocol::{BusEvent, ProcessInfo, Request, Response};
use shep_core::selector::ProcessSelector;
use crate::cli::{BleatsArgs, Format};
use crate::commands::rpc::{client_error, unexpected_response};
use crate::commands::selector::parse_selector;
use crate::exit::ExitCode;
use crate::output::{self, Streams, write_outcome};
#[derive(Debug, Serialize)]
struct BleatLine<'a> {
schema_version: u32,
id: u32,
name: &'a str,
stream: &'static str,
instance: Option<u32>,
line: &'a str,
}
async fn resolve_names(
client: &Client,
streams: &mut Streams<'_>,
) -> Result<HashMap<u32, ProcessInfo>, ExitCode> {
match client.request(Request::ListFlock).await {
Ok(Response::Flock(procs)) => Ok(procs.into_iter().map(|p| (p.id, p)).collect()),
Ok(_unrecognised) => Err(unexpected_response(streams)),
Err(err) => Err(client_error(streams, &err)),
}
}
async fn subscribe(client: &Client, streams: &mut Streams<'_>) -> Result<EventStream, ExitCode> {
let topics = vec!["log.*".to_string(), "daemon.*".to_string()];
match client.subscribe(topics).await {
Ok(stream) => Ok(stream),
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &err.to_string()))
}
}
}
fn resolved_name(cache: &HashMap<u32, ProcessInfo>, id: u32) -> String {
cache
.get(&id)
.map_or_else(|| id.to_string(), |info| info.name.clone())
}
fn resolved_instance(cache: &HashMap<u32, ProcessInfo>, id: u32) -> Option<u32> {
let info = cache.get(&id)?;
if instance_count(cache, &info.name) > 1 {
info.instance
} else {
None
}
}
fn selector_allows(selector: &ProcessSelector, cache: &HashMap<u32, ProcessInfo>, id: u32) -> bool {
match cache.get(&id) {
Some(info) => selector.matches(&info.name, info.id, info.fold.as_deref(), info.instance),
None => selector.matches("", id, None, None),
}
}
fn write_line(
out: &mut dyn io::Write,
fmt: Format,
id: u32,
name: &str,
instance: Option<u32>,
stream: &'static str,
line: &str,
) -> io::Result<()> {
match fmt {
Format::Json => {
let payload = BleatLine {
schema_version: output::SCHEMA_VERSION,
id,
name,
instance,
stream,
line,
};
serde_json::to_writer(&mut *out, &payload)?;
writeln!(out)
}
Format::Table => match instance {
Some(slot) => writeln!(out, "{name}:{slot} | {line}"),
None => writeln!(out, "{name} | {line}"),
},
}
}
fn instance_count(cache: &HashMap<u32, ProcessInfo>, name: &str) -> usize {
cache.values().filter(|info| info.name == name).count()
}
fn write_notice(streams: &mut Streams<'_>, quiet: bool, code: &str, message: &str) {
if quiet {
return;
}
streams.aside(code, message);
}
const TAIL_WINDOW_BYTES: u64 = 256 * 1024;
pub(crate) fn read_tail(path: &Path, limit: usize) -> io::Result<(Vec<String>, bool)> {
let mut file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
let start = len.saturating_sub(TAIL_WINDOW_BYTES);
let window_truncated = start > 0;
if start > 0 {
file.seek(SeekFrom::Start(start))?;
}
let mut window = Vec::new();
file.read_to_end(&mut window)?;
let window: &[u8] = if start > 0 {
match window.iter().position(|&b| b == b'\n') {
Some(newline) => &window[newline + 1..],
None => &[],
}
} else {
&window
};
let text = String::from_utf8_lossy(window);
let mut lines: Vec<String> = text
.split('\n')
.map(|line| shep_core::logstamp::strip(line).to_string())
.collect();
if lines.last().is_some_and(String::is_empty) {
lines.pop();
}
let keep_from = lines.len().saturating_sub(limit);
let truncated = window_truncated || keep_from > 0;
lines.drain(..keep_from);
Ok((lines, truncated))
}
pub(crate) fn missing_log_note(stream: &str, path: &Path) -> String {
if !path.is_relative() {
return format!("no {stream} log at {} yet", path.display());
}
let tried = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
format!(
"no {stream} log at {}; {stream}_file is relative, so the shepherd \
resolves it against its own directory instead",
tried.display()
)
}
fn tail_log_files(
streams: &mut Streams<'_>,
quiet: bool,
cache: &HashMap<u32, ProcessInfo>,
selector: &ProcessSelector,
args: &BleatsArgs,
) -> ExitCode {
let mut matched: Vec<&ProcessInfo> = cache
.values()
.filter(|info| selector.matches(&info.name, info.id, info.fold.as_deref(), info.instance))
.collect();
matched.sort_unstable_by(|a, b| {
(a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
});
let mut failure = false;
let mut seen_paths: HashSet<String> = HashSet::new();
let mut seen_notices: HashSet<String> = HashSet::new();
let mut path_owners: HashMap<(&'static str, String), usize> = HashMap::new();
for info in cache.values() {
for (stream_name, path) in [
("out", info.out_file.as_deref()),
("err", info.err_file.as_deref()),
] {
if let Some(path) = path {
*path_owners
.entry((stream_name, path.to_string()))
.or_insert(0) += 1;
}
}
}
for info in matched {
let name = &info.name;
let wanted: [(&'static str, Option<&str>, bool); 2] = [
("out", info.out_file.as_deref(), !args.err),
("err", info.err_file.as_deref(), !args.out),
];
for (stream_name, path, show) in wanted {
if !show {
continue;
}
match path {
None => {
let message =
format!("{name}: the daemon did not report a {stream_name} log path");
if seen_notices.insert(message.clone()) {
write_notice(streams, quiet, "log_path_unknown", &message);
}
}
Some(path) => {
if !seen_paths.insert(path.to_string()) {
continue;
}
let shared = path_owners
.get(&(stream_name, path.to_string()))
.copied()
.unwrap_or(0)
> 1;
let label_instance = if !shared && instance_count(cache, name) > 1 {
info.instance
} else {
None
};
match read_tail(Path::new(path), args.lines) {
Ok((lines, _truncated)) => {
for line in lines {
if let Err(write_err) = write_line(
streams.out,
streams.fmt,
info.id,
name,
label_instance,
stream_name,
&line,
) {
let code = write_outcome(Err(write_err));
let _ = streams.out.flush();
return code;
}
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
write_notice(
streams,
quiet,
"log_missing",
&format!(
"{name}: {}",
missing_log_note(stream_name, Path::new(path))
),
);
}
Err(err) => {
failure = true;
write_notice(
streams,
quiet,
"log_unreadable",
&format!("failed to read {path}: {err}"),
);
}
}
}
}
}
}
let _ = streams.out.flush();
if failure {
ExitCode::Failure
} else {
ExitCode::Success
}
}
fn handle_event(
streams: &mut Streams<'_>,
quiet: bool,
cache: &HashMap<u32, ProcessInfo>,
selector: &ProcessSelector,
args: &BleatsArgs,
event: BusEvent,
) -> io::Result<()> {
match event {
BusEvent::LogOut { id, line } => {
if !args.err && selector_allows(selector, cache, id) {
let name = resolved_name(cache, id);
let instance = resolved_instance(cache, id);
write_line(streams.out, streams.fmt, id, &name, instance, "out", &line)?;
}
Ok(())
}
BusEvent::LogErr { id, line } => {
if !args.out && selector_allows(selector, cache, id) {
let name = resolved_name(cache, id);
let instance = resolved_instance(cache, id);
write_line(streams.out, streams.fmt, id, &name, instance, "err", &line)?;
}
Ok(())
}
BusEvent::Dropped { count } => {
write_notice(
streams,
quiet,
"dropped",
&format!("the daemon dropped {count} events (its own queue overflowed)"),
);
Ok(())
}
BusEvent::DaemonShutdown => {
write_notice(
streams,
quiet,
"daemon_shutdown",
"the daemon is shutting down",
);
Ok(())
}
_ => Ok(()),
}
}
pub async fn bleats(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
) -> ExitCode {
bleats_with_signal(
client,
streams,
quiet,
args,
tokio::signal::ctrl_c().map(|_| ()),
)
.await
}
pub async fn bleats_with_signal(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
interrupt: impl std::future::Future<Output = ()> + Send,
) -> ExitCode {
let selector = match parse_selector(streams, &args.selector) {
Ok(selector) => selector,
Err(code) => return code,
};
let cache = match resolve_names(client, streams).await {
Ok(cache) => cache,
Err(code) => return code,
};
if args.no_follow {
return tail_log_files(streams, quiet, &cache, &selector, args);
}
if args.lines > 0 {
let _ = tail_log_files(streams, quiet, &cache, &selector, args);
}
let mut stream = match subscribe(client, streams).await {
Ok(stream) => stream,
Err(code) => return code,
};
tokio::pin!(interrupt);
loop {
tokio::select! {
biased;
item = stream.next() => {
match item {
Some(Ok(event)) => {
if let Err(write_err) =
handle_event(streams, quiet, &cache, &selector, args, event)
{
let code = write_outcome(Err(write_err));
let _ = streams.out.flush();
return code;
}
}
Some(Err(Lagged { count })) => {
write_notice(
streams,
quiet,
"lagged",
&format!("{count} events dropped locally (lagged)"),
);
}
None => {
let _ = streams.out.flush();
return ExitCode::DaemonUnreachable;
}
}
}
() = &mut interrupt => {
let _ = streams.out.flush();
return ExitCode::Success;
}
}
}
}
#[cfg(test)]
mod tests;