use std::path::Path;
use std::sync::Arc;
use anyhow::Context;
use crate::db::failure_record::{self, FailureKind};
use crate::util::UnwrapPoison;
static PRE_TRACING_DIAGNOSTICS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
static TRACING_INITIALIZED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub(crate) fn timestamped_stderr(message: &str) {
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
crate::util::print_stderr(&format!("[mahbot] [{ts}] {message}"));
}
pub(crate) fn boot_diagnostic(message: String) {
timestamped_stderr(&message);
if TRACING_INITIALIZED.load(std::sync::atomic::Ordering::Acquire) {
tracing::warn!("{message}");
} else {
PRE_TRACING_DIAGNOSTICS.lock().unwrap_poison().push(message);
}
}
pub(crate) fn mark_tracing_initialized() {
TRACING_INITIALIZED.store(true, std::sync::atomic::Ordering::Release);
}
pub(crate) fn clear_boot_diagnostics() {
PRE_TRACING_DIAGNOSTICS.lock().unwrap_poison().clear();
}
pub(crate) fn replay_boot_diagnostics() {
let messages = {
let mut buf = PRE_TRACING_DIAGNOSTICS.lock().unwrap_poison();
std::mem::take(&mut *buf)
};
for m in messages {
tracing::warn!("{m}");
}
}
pub async fn open_stores() -> anyhow::Result<Arc<crate::logs::LogStore>> {
let root = crate::config::CONFIG
.try_storage_root()
.context("config::load_or_init() must run before the stores are opened")
.map_err(|e| record_startup_failure("boot::open_stores", e))?;
let refusals: Vec<crate::db::StoreRefusal> = crate::db::wal_guard::scan_store_shapes(&root)
.iter()
.map(|store| {
crate::db::StoreRefusal::new(store.store, &store.db_path, store.defect.reason())
.with_environment(store.defect.is_environment_caused())
})
.collect();
if !refusals.is_empty() {
for refusal in &refusals {
record_refusal(&root, refusal);
}
let named = refusals
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
return Err(anyhow::anyhow!(named));
}
let (log_store, log_broadcast) = crate::logs::init_tracing(&root)
.await
.map_err(|e| record_bring_up_failure(&root, "logs", e))?;
let _ = crate::gui::LOG_BROADCAST.set(log_broadcast);
crate::search_engine::init_global(); crate::pipeline::chronicle::init_global(); crate::agent::message_router::init_global()
.map_err(|e| record_startup_failure("agent::message_router::init_global", e))?;
#[cfg(target_os = "macos")]
{
crate::audio::voice::init_global()
.map_err(|e| record_startup_failure("audio::voice::init_global", e))?;
crate::audio::tts::init_global()
.map_err(|e| record_startup_failure("audio::tts::init_global", e))?;
}
crate::db::init_all_stores()
.await
.map_err(|e| record_bring_up_failure(&root, "core", e))?;
Ok(log_store)
}
fn note_recorded(what: &str, filed: Option<failure_record::Filed>) {
if let Some(pointer) = failure_record::recorded_pointer(what, filed) {
boot_diagnostic(pointer);
}
}
fn record_refusal(root: &Path, refusal: &crate::db::StoreRefusal) {
boot_diagnostic(refusal.to_string());
note_recorded(
"start-up refusal",
failure_record::record_startup_refusal(
root,
refusal.store,
&refusal.db_path,
&refusal.reason,
refusal.environment,
),
);
}
fn record_bring_up_failure(root: &Path, store: &'static str, e: anyhow::Error) -> anyhow::Error {
if let Some(refusal) = e
.chain()
.find_map(|cause| cause.downcast_ref::<crate::db::StoreRefusal>())
{
record_refusal(root, refusal);
} else if crate::db::is_store_lock_error(&e) {
let reason = format!("a store is locked by another process — {e:#}");
boot_diagnostic(format!("refusing to start: {reason}"));
note_recorded(
"start-up refusal",
failure_record::record_startup_refusal(
root,
store,
&crate::db::store_db_path(root, store),
&reason,
true,
),
);
} else {
let report = failure_record::start_up_report(
FailureKind::StartUpFailure,
Some((store, crate::db::store_db_path(root, store))),
format!("{e:#}"),
crate::db::is_actionable_signal(&e),
);
note_recorded(
"start-up failure",
failure_record::record(Some(root), &report.render()),
);
}
e
}
#[must_use]
#[doc(hidden)]
pub fn record_startup_failure(context: &str, e: anyhow::Error) -> anyhow::Error {
let block = start_up_block(context, &e);
note_recorded(
"start-up failure",
failure_record::record(crate::config::CONFIG.try_storage_root().as_deref(), &block),
);
e
}
#[must_use]
#[doc(hidden)]
pub fn record_launch_failure(root: &Path, context: &str, e: anyhow::Error) -> anyhow::Error {
let block = start_up_block(context, &e);
note_recorded(
"start-up failure",
failure_record::record(Some(root), &block),
);
e
}
fn start_up_block(context: &str, e: &anyhow::Error) -> String {
failure_record::start_up_report(
FailureKind::StartUpFailure,
None,
format!("{e:#}"),
crate::db::is_actionable_signal(e),
)
.step(context)
.render()
}
#[must_use]
#[doc(hidden)]
pub fn startup_panic_error(message: &str) -> anyhow::Error {
anyhow::anyhow!("Startup panicked: {message}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startup_refusal_is_recorded_durably() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let root = tmp.path();
let db_path = crate::db::store_db_path(root, "core");
let refusal =
crate::db::StoreRefusal::new("core", &db_path, "the data file is empty (0 bytes)");
let err = record_bring_up_failure(root, "core", anyhow::Error::new(refusal));
assert!(
format!("{err:#}").starts_with("refusing to start: store 'core' is not usable"),
"got: {err:#}"
);
let body = std::fs::read_to_string(root.join("error.log")).expect("error.log written");
for needle in [
"MahBot start-up refusal",
"store: core",
"db path:",
"reason: the data file is empty (0 bytes)",
] {
assert!(
body.contains(needle),
"error.log must contain {needle:?}: {body}"
);
}
let wrapped = anyhow::Error::new(crate::db::StoreRefusal::new(
"logs",
&crate::db::store_db_path(root, "logs"),
"the data file is too short for a valid header (4 bytes)",
))
.context("opening the logs store");
let err = record_bring_up_failure(root, "logs", wrapped);
assert!(
format!("{err:#}").contains("refusing to start: store 'logs' is not usable"),
"got: {err:#}"
);
let body = std::fs::read_to_string(root.join("error.log")).expect("error.log written");
assert!(
body.contains("store: logs") && body.contains("(4 bytes)"),
"a wrapped refusal must still be recorded: {body}"
);
let defect = crate::db::wal_guard::ShapeDefect::Unreadable;
assert!(
defect.is_environment_caused(),
"a file that could not be read is not evidence of damage"
);
let unreadable = crate::db::StoreRefusal::new("core", &db_path, defect.reason())
.with_environment(defect.is_environment_caused());
record_bring_up_failure(root, "core", anyhow::Error::new(unreadable));
let body = std::fs::read_to_string(root.join("error.log")).expect("error.log written");
assert!(
body.contains(failure_record::ENVIRONMENT_CAUSE),
"an environment-caused refusal must say so: {body}"
);
}
#[test]
fn non_refusal_bring_up_failure_is_recorded() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let root = tmp.path();
let passed = record_bring_up_failure(
root,
"core",
anyhow::anyhow!("permission denied").context("opening the core store"),
);
assert_eq!(
format!("{passed:#}"),
"opening the core store: permission denied"
);
let body = std::fs::read_to_string(root.join("error.log")).expect("error.log written");
for needle in [
"MahBot start-up failure",
"store: core",
"db path:",
failure_record::ENVIRONMENT_CAUSE,
"reason: opening the core store: permission denied",
] {
assert!(
body.contains(needle),
"error.log must contain {needle:?}: {body}"
);
}
record_bring_up_failure(root, "logs", anyhow::anyhow!("the schema probe failed"));
let body = std::fs::read_to_string(root.join("error.log")).expect("error.log written");
assert!(
body.contains("reason: the schema probe failed"),
"a non-environment failure must still be recorded: {body}"
);
assert_eq!(
body.matches(failure_record::ENVIRONMENT_CAUSE).count(),
1,
"only the environment-caused failure may carry the cause line: {body}"
);
}
#[test]
fn non_store_startup_failure_is_recorded() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let passed = record_launch_failure(
tmp.path(),
"providers::init_global",
anyhow::anyhow!("no provider credential"),
);
assert_eq!(format!("{passed:#}"), "no provider credential");
let body =
std::fs::read_to_string(tmp.path().join("error.log")).expect("error.log written");
for needle in [
"MahBot start-up failure",
failure_record::NO_STORE,
failure_record::NO_DB_PATH,
"step: providers::init_global",
"reason: no provider credential",
] {
assert!(
body.contains(needle),
"error.log must contain {needle:?}: {body}"
);
}
}
#[test]
fn startup_panic_keeps_its_qualifier() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let returned = record_launch_failure(tmp.path(), "bootstrap", startup_panic_error("boom"));
assert_eq!(
format!("{returned:#}"),
"Startup panicked: boom",
"the start-failure screen shows the returned error verbatim"
);
let body =
std::fs::read_to_string(tmp.path().join("error.log")).expect("error.log written");
assert!(
body.contains("step: bootstrap") && body.contains("reason: Startup panicked: boom"),
"the record names the step and keeps the panic sentence as the reason: {body}"
);
}
}