use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
use futures_util::FutureExt;
use serde::Serialize;
use shep_client::{Client, EventStream, Lagged, RequestError};
use shep_core::protocol::{BusEvent, LogStream, ProcessInfo};
use shep_core::selector::ProcessSelector;
use crate::cli::{BleatsArgs, Format};
use crate::commands::rpc::typed_request_error;
use crate::commands::selector::parse_selector;
use crate::exit::ExitCode;
use crate::output::{self, Streams, write_outcome};
use crate::remote::interrupt_within_grace;
use crate::shutdown::Interrupt;
mod source;
use source::stream_label;
pub(crate) use source::{FileTail, LogSource, predates_tail_log};
pub(crate) use shep_core::logtail::read_tail;
#[cfg(test)]
pub(crate) use shep_core::logtail::TAIL_WINDOW_BYTES;
#[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) -> Result<HashMap<u32, ProcessInfo>, RequestError> {
client
.list_flock()
.await
.map(|procs| procs.into_iter().map(|p| (p.id, p)).collect())
}
async fn subscribe(client: &Client) -> Result<EventStream, RequestError> {
let topics = vec!["log.*".to_string(), "daemon.*".to_string()];
client.subscribe(topics).await
}
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>,
counts: &HashMap<String, usize>,
id: u32,
) -> Option<u32> {
let info = cache.get(&id)?;
if counts.get(info.name.as_str()).copied().unwrap_or(0) > 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(
streams: &mut Streams<'_>,
id: u32,
name: &str,
instance: Option<u32>,
stream: &'static str,
line: &str,
) -> io::Result<()> {
match streams.fmt {
Format::Json => {
let payload = BleatLine {
schema_version: output::SCHEMA_VERSION,
id,
name,
instance,
stream,
line,
};
serde_json::to_writer(&mut *streams.out, &payload)?;
writeln!(streams.out)
}
Format::Table => {
let clean = output::log_line::sanitize_log_line(line);
let text = output::log_line::paint_default(&clean, stream, streams.style);
match instance {
Some(slot) => writeln!(streams.out, "{name}:{slot} | {text}"),
None => writeln!(streams.out, "{name} | {text}"),
}
}
}
}
fn instance_count(cache: &HashMap<u32, ProcessInfo>, name: &str) -> usize {
cache.values().filter(|info| info.name == name).count()
}
fn instance_counts(cache: &HashMap<u32, ProcessInfo>) -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::with_capacity(cache.len());
for info in cache.values() {
*counts.entry(info.name.clone()).or_insert(0) += 1;
}
counts
}
fn write_notice(streams: &mut Streams<'_>, quiet: bool, code: &str, message: &str) {
if quiet {
return;
}
streams.aside(code, message);
}
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 path_unknown_notice(
streams: &mut Streams<'_>,
quiet: bool,
seen_notices: &mut HashSet<String>,
name: &str,
stream: LogStream,
) {
let message = format!(
"{name}: the daemon did not report a {} log path",
stream_label(stream)
);
if seen_notices.insert(message.clone()) {
write_notice(streams, quiet, "log_path_unknown", &message);
}
}
async fn tail_log_files(
streams: &mut Streams<'_>,
quiet: bool,
cache: &HashMap<u32, ProcessInfo>,
selector: &ProcessSelector,
args: &BleatsArgs,
source: LogSource<'_>,
) -> Result<ExitCode, RequestError> {
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<&str> = HashSet::new();
let mut seen_notices: HashSet<String> = HashSet::new();
let mut path_owners: HashMap<(&'static str, &str), 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)).or_insert(0) += 1;
}
}
}
for info in matched {
let name = &info.name;
let wanted: [(LogStream, Option<&str>, bool); 2] = [
(LogStream::Out, info.out_file.as_deref(), !args.err),
(LogStream::Err, info.err_file.as_deref(), !args.out),
];
for (stream, path, show) in wanted {
if !show {
continue;
}
let stream_name = stream_label(stream);
let Some(path) = path else {
path_unknown_notice(streams, quiet, &mut seen_notices, name, stream);
continue;
};
if !seen_paths.insert(path) {
continue;
}
let shared = path_owners.get(&(stream_name, path)).copied().unwrap_or(0) > 1;
let label_instance = if !shared && instance_count(cache, name) > 1 {
info.instance
} else {
None
};
match source.read(info, stream, path, args.lines).await {
Ok(FileTail::Lines { lines, .. }) => {
for line in lines {
if let Err(write_err) =
write_line(streams, info.id, name, label_instance, stream_name, &line)
{
let code = write_outcome(Err(write_err));
let _ = streams.out.flush();
return Ok(code);
}
}
}
Ok(FileTail::NoPath) => {
path_unknown_notice(streams, quiet, &mut seen_notices, name, stream);
}
Ok(FileTail::Missing(note)) => {
write_notice(streams, quiet, "log_missing", &format!("{name}: {note}"));
}
Ok(FileTail::Unreadable(message)) => {
failure = true;
write_notice(streams, quiet, "log_unreadable", &message);
}
Err(err) => return Err(err),
}
}
}
let _ = streams.out.flush();
Ok(if failure {
ExitCode::Failure
} else {
ExitCode::Success
})
}
fn stop_a_read(source: LogSource<'_>) -> impl FnOnce(&mut Streams<'_>, &RequestError) -> ExitCode {
move |streams, err| source.stop(streams, err)
}
#[allow(clippy::too_many_arguments)]
fn handle_log_line(
streams: &mut Streams<'_>,
cache: &HashMap<u32, ProcessInfo>,
counts: &HashMap<String, usize>,
selector: &ProcessSelector,
id: u32,
line: &str,
stream: &'static str,
suppressed: bool,
) -> io::Result<()> {
if !suppressed && selector_allows(selector, cache, id) {
let name = resolved_name(cache, id);
let instance = resolved_instance(cache, counts, id);
write_line(streams, id, &name, instance, stream, line)?;
}
Ok(())
}
fn handle_event(
streams: &mut Streams<'_>,
quiet: bool,
cache: &HashMap<u32, ProcessInfo>,
counts: &HashMap<String, usize>,
selector: &ProcessSelector,
args: &BleatsArgs,
event: BusEvent,
) -> io::Result<()> {
match event {
BusEvent::LogOut { id, line } => {
handle_log_line(streams, cache, counts, selector, id, &line, "out", args.err)
}
BusEvent::LogErr { id, line } => {
handle_log_line(streams, cache, counts, selector, id, &line, "err", args.out)
}
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_armed(client, streams, quiet, args, None).await
}
pub async fn bleats_on_host(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
host: &str,
args: &BleatsArgs,
) -> ExitCode {
bleats_armed(client, streams, quiet, args, Some(host)).await
}
async fn bleats_armed(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
host: Option<&str>,
) -> ExitCode {
let mut interrupt = match Interrupt::install() {
Ok(interrupt) => interrupt,
Err(err) => {
let message = format!("listening for an interrupt: {err}");
return streams.fail(ExitCode::Failure, &message);
}
};
bleats_over(
client,
streams,
quiet,
args,
host,
interrupt.recv().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 {
bleats_over(client, streams, quiet, args, None, interrupt).await
}
async fn link_gone<F, R>(
streams: &mut Streams<'_>,
host: Option<&str>,
err: &RequestError,
interrupt: &mut F,
report: R,
) -> ExitCode
where
F: std::future::Future<Output = ()> + Unpin,
R: FnOnce(&mut Streams<'_>, &RequestError) -> ExitCode,
{
let _ = streams.out.flush();
if host.is_some()
&& matches!(err, RequestError::Closed)
&& interrupt_within_grace(interrupt).await
{
return ExitCode::Success;
}
let code = report(streams, err);
let _ = streams.err.flush();
code
}
async fn raced<F, T, W>(interrupt: &mut F, work: W) -> Option<T>
where
F: std::future::Future<Output = ()> + Unpin,
W: std::future::Future<Output = T>,
{
tokio::select! {
biased;
value = work => Some(value),
() = &mut *interrupt => None,
}
}
fn interrupt_exit(streams: &mut Streams<'_>) -> ExitCode {
let _ = streams.out.flush();
ExitCode::Success
}
async fn bleats_over(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
host: Option<&str>,
interrupt: impl std::future::Future<Output = ()> + Send,
) -> ExitCode {
tokio::pin!(interrupt);
let selector = match parse_selector(streams, &args.selector) {
Ok(selector) => selector,
Err(code) => return code,
};
let source = match host {
Some(name) => LogSource::Host { client, name },
None => LogSource::Local,
};
let cache = match raced(&mut interrupt, resolve_names(client)).await {
None => return interrupt_exit(streams),
Some(Ok(cache)) => cache,
Some(Err(err)) => {
return link_gone(streams, host, &err, &mut interrupt, typed_request_error).await;
}
};
if args.no_follow {
let tail = tail_log_files(streams, quiet, &cache, &selector, args, source);
return match raced(&mut interrupt, tail).await {
None => interrupt_exit(streams),
Some(Ok(code)) => code,
Some(Err(err)) => {
link_gone(streams, host, &err, &mut interrupt, stop_a_read(source)).await
}
};
}
let counts = instance_counts(&cache);
if args.lines > 0 {
let backlog = tail_log_files(streams, quiet, &cache, &selector, args, source);
match raced(&mut interrupt, backlog).await {
None => return interrupt_exit(streams),
Some(Err(err)) => {
return link_gone(streams, host, &err, &mut interrupt, stop_a_read(source)).await;
}
Some(Ok(_)) => {}
}
}
let mut stream = match raced(&mut interrupt, subscribe(client)).await {
None => return interrupt_exit(streams),
Some(Ok(stream)) => stream,
Some(Err(err)) => {
return link_gone(streams, host, &err, &mut interrupt, typed_request_error).await;
}
};
loop {
tokio::select! {
biased;
item = stream.next() => {
match item {
Some(Ok(event)) => {
if let Err(write_err) =
handle_event(streams, quiet, &cache, &counts, &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();
if host.is_some() && interrupt_within_grace(&mut interrupt).await {
return ExitCode::Success;
}
return ExitCode::DaemonUnreachable;
}
}
}
() = &mut interrupt => {
return interrupt_exit(streams);
}
}
}
}
#[cfg(test)]
mod tests;