use std::fs::File;
use std::io::{self, Write as _};
use std::panic::{self, PanicHookInfo};
use std::sync::PoisonError;
use std::thread;
use std::time::Instant;
use super::{Level, StderrLogger, line};
const PANIC_TARGET: &str = "panic";
const NON_STRING_PAYLOAD: &str = "<non-string payload>";
pub(super) fn install(logger: &StderrLogger) -> io::Result<()> {
if thread::panicking() {
return Err(io::Error::other(
"attached while this thread was panicking, when the panic hook cannot be swapped",
));
}
let file = logger
.file
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.ok_or_else(|| io::Error::other("no log file is attached"))?
.try_clone_file()?;
let start = logger.start;
let previous = panic::take_hook();
panic::set_hook(Box::new(move |info| {
write_panic(&file, start, info);
previous(info);
}));
Ok(())
}
fn write_panic(mut file: &File, start: Instant, info: &PanicHookInfo<'_>) {
let thread = thread::current();
let name = thread
.name()
.map_or_else(|| format!("{:?}", thread.id()), str::to_owned);
let message = info.payload_as_str().unwrap_or(NON_STRING_PAYLOAD);
let text = match info.location() {
Some(location) => format!("thread '{name}' panicked at {location}: {message}"),
None => format!("thread '{name}' panicked at an unknown location: {message}"),
};
let line = line(
start.elapsed(),
Level::Error,
PANIC_TARGET,
format_args!("{text}"),
);
let _ = file.write_all(line.as_bytes());
}
#[cfg(test)]
mod tests {
use std::any::Any;
use std::env;
use std::fs;
use std::io::Read as _;
use std::panic::Location;
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use std::thread::ThreadId;
use std::time::Duration;
use super::super::file::tests::TempDir;
use super::super::{
Filter, LOGGER, attach_file, attach_file_without_panic_hook, try_init_logging,
};
use super::*;
const CHILD_DIR: &str = "CRCBL_LOG_PANIC_HOOK_CHILD_DIR";
const CHILD_DEADLINE: Duration = Duration::from_secs(30);
const STEM: &str = "game";
fn in_child(name: &str, body: impl FnOnce(&Path)) {
if let Some(dir) = env::var_os(CHILD_DIR) {
assert!(
try_init_logging(Filter::parse("error")).is_ok(),
"a fresh process has a free logger slot"
);
body(Path::new(&dir));
return;
}
let temp = TempDir::new(&format!("panic-hook-{name}"));
let mut child =
crcbl_spawn::command(env::current_exe().expect("a test binary knows its own path"))
.args(["--exact", &format!("log::panic_hook::tests::{name}")])
.env(CHILD_DIR, &temp.0)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("re-running this test binary");
let drain = |pipe: Option<Box<dyn io::Read + Send>>| {
thread::spawn(move || {
let mut text = String::new();
if let Some(mut pipe) = pipe {
let _ = pipe.read_to_string(&mut text);
}
text
})
};
let stdout = drain(child.stdout.take().map(|p| Box::new(p) as _));
let stderr = drain(child.stderr.take().map(|p| Box::new(p) as _));
let started = Instant::now();
let status = loop {
if let Some(status) = child.try_wait().expect("polling the child") {
break Some(status);
}
if started.elapsed() > CHILD_DEADLINE {
let _ = child.kill();
let _ = child.wait();
break None;
}
thread::sleep(Duration::from_millis(10));
};
let report = format!(
"{}{}",
stdout.join().expect("the stdout reader"),
stderr.join().expect("the stderr reader")
);
let status =
status.unwrap_or_else(|| panic!("the child hung past {CHILD_DEADLINE:?}:\n{report}"));
assert!(status.success(), "the child failed:\n{report}");
assert!(
report.contains("1 passed"),
"the child ran no test — has this one been renamed?\n{report}"
);
}
fn panic_on_thread<M: Any + Send>(name: Option<&str>, payload: M) -> (ThreadId, String) {
#[track_caller]
fn raise<M: Any + Send>(sender: &mpsc::Sender<String>, payload: M) {
sender
.send(Location::caller().to_string())
.expect("the test is listening");
panic::panic_any(payload);
}
let (sender, location) = mpsc::channel();
let builder = match name {
Some(name) => thread::Builder::new().name(name.to_owned()),
None => thread::Builder::new(),
};
let worker = builder
.spawn(move || raise(&sender, payload))
.expect("spawning the panicking thread");
let id = worker.thread().id();
worker.join().expect_err("the thread panicked");
(
id,
location.recv().expect("the thread said where it panics"),
)
}
fn read_log(dir: &Path) -> String {
fs::read_to_string(dir.join(format!("{STEM}.log"))).expect("reading the log")
}
fn panic_lines(text: &str) -> Vec<&str> {
let marker = format!(" ERROR {PANIC_TARGET}] ");
text.lines().filter(|line| line.contains(&marker)).collect()
}
#[test]
#[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
fn a_panic_writes_its_message_location_and_thread() {
in_child("a_panic_writes_its_message_location_and_thread", |dir| {
attach_file(dir, STEM).expect("attaching the file");
let (_, location) =
panic_on_thread(Some("reactor"), format!("the core is {}", "on fire"));
let text = read_log(dir);
let lines = panic_lines(&text);
assert_eq!(lines.len(), 1, "{text}");
let written = lines[0]
.split_once(&format!("{PANIC_TARGET}] "))
.expect("the line's target")
.1;
let (at, message) = written
.strip_prefix("thread 'reactor' panicked at ")
.and_then(|rest| rest.split_once(": "))
.unwrap_or_else(|| panic!("not the hook's shape: {written}"));
assert_eq!(at, location);
assert_eq!(message, "the core is on fire");
});
}
#[test]
#[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
fn an_unnamed_thread_and_a_non_string_payload_still_get_a_line() {
in_child(
"an_unnamed_thread_and_a_non_string_payload_still_get_a_line",
|dir| {
attach_file(dir, STEM).expect("attaching the file");
let (id, _) = panic_on_thread(None, 7_u8);
let (_, _) = panic_on_thread(None, "a static str");
let text = read_log(dir);
let lines = panic_lines(&text);
assert_eq!(lines.len(), 2, "{text}");
assert!(
lines[0].contains(&format!("thread '{id:?}' panicked at "))
&& lines[0].ends_with(&format!(": {NON_STRING_PAYLOAD}")),
"{text}"
);
assert!(lines[1].ends_with(": a static str"), "{text}");
},
);
}
#[test]
#[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
fn the_previous_hook_still_runs() {
in_child("the_previous_hook_still_runs", |dir| {
static CALLS: AtomicUsize = AtomicUsize::new(0);
let default = panic::take_hook();
panic::set_hook(Box::new(move |info| {
CALLS.fetch_add(1, Ordering::Relaxed);
default(info);
}));
attach_file(dir, STEM).expect("attaching the file");
panic_on_thread(Some("worker"), "once");
assert_eq!(CALLS.load(Ordering::Relaxed), 1, "the game's hook ran");
assert_eq!(panic_lines(&read_log(dir)).len(), 1, "and so did ours");
});
}
#[test]
#[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
fn a_second_attach_installs_no_second_hook() {
in_child("a_second_attach_installs_no_second_hook", |dir| {
attach_file(dir, STEM).expect("attaching the file");
for again in [attach_file, attach_file_without_panic_hook] {
let error = again(dir, STEM).expect_err("a second file for the run");
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
}
panic_on_thread(Some("worker"), "once");
let text = read_log(dir);
assert_eq!(panic_lines(&text).len(), 1, "{text}");
});
}
#[test]
#[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
fn attaching_without_the_hook_installs_none() {
in_child("attaching_without_the_hook_installs_none", |dir| {
attach_file_without_panic_hook(dir, STEM).expect("attaching the file");
panic_on_thread(Some("worker"), "unrecorded");
crate::error!("after the panic");
let text = read_log(dir);
assert!(panic_lines(&text).is_empty(), "{text}");
assert!(
text.contains("after the panic"),
"the file is attached: {text}"
);
});
}
#[test]
#[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
fn a_panic_holding_the_file_lock_still_writes_its_line() {
in_child(
"a_panic_holding_the_file_lock_still_writes_its_line",
|dir| {
attach_file(dir, STEM).expect("attaching the file");
let logger = LOGGER.get().expect("the logger is installed");
thread::Builder::new()
.name("holder".to_owned())
.spawn(move || {
let _held = logger.file.lock().unwrap_or_else(PoisonError::into_inner);
panic!("panicked holding the file lock");
})
.expect("spawning the panicking thread")
.join()
.expect_err("the thread panicked");
assert!(logger.file.is_poisoned(), "the unwind poisoned the lock");
crate::error!("after the poisoned lock");
let text = read_log(dir);
let lines = panic_lines(&text);
assert_eq!(lines.len(), 1, "{text}");
assert!(
lines[0].contains("thread 'holder' panicked at ")
&& lines[0].ends_with(": panicked holding the file lock"),
"{text}"
);
assert!(text.contains("after the poisoned lock"), "{text}");
},
);
}
}