use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};
use anstream::eprintln;
use crate::meta::Project;
use crate::targets::{Target, TargetKind};
use crate::term::DIM;
const MAX_LINES: usize = 60;
const DEVICE_CMD: Duration = Duration::from_secs(30);
const DEBUGGER: Duration = Duration::from_secs(90);
const MAX_FRAMES: usize = 25;
struct Finding {
source: String,
body: String,
}
pub fn after_app_death(project: &Project, target: &'static Target, since: SystemTime) -> bool {
let app_id = project.manifest.resolve(target.name).id;
let mut findings = Vec::new();
let mut looked: Vec<String> = Vec::new();
day_break_findings(project, target, &app_id, since, &mut findings, &mut looked);
os_crash_findings(project, target, since, &mut findings, &mut looked);
if findings.is_empty() {
crate::ops::status(
"Diagnosis",
&format!(
"no crash artifact found for {} — looked at: {}",
target.name,
looked.join(", ")
),
);
return false;
}
for f in &findings {
crate::ops::status("Diagnosis", &f.source);
eprintln!("{DIM}{}{DIM:#}", indent(&f.body));
}
if crate::ops::github_actions() {
let headline = findings
.iter()
.find_map(|f| headline_of(&f.body))
.unwrap_or_else(|| findings[0].source.clone());
println!(
"::error title=day: {} crashed under a dayscript::{}",
target.name,
crate::ops::gha_escape(&headline)
);
}
true
}
fn day_break_findings(
project: &Project,
target: &'static Target,
app_id: &str,
since: SystemTime,
out: &mut Vec<Finding>,
looked: &mut Vec<String>,
) {
let Some(dir) = break_store_dir(project, target, app_id) else {
return;
};
looked.push(format!("day-break store ({})", dir.display()));
if !dir.is_dir() {
return;
}
let mut files = newest_files(&dir.join("reports"), 4);
files.extend(newest_files(&dir, 6));
let fresh: Vec<PathBuf> = files
.into_iter()
.filter(|p| modified_since(p, since))
.filter(|p| std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false))
.collect();
if fresh.is_empty() {
return;
}
for path in fresh {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if !describes_this_run(&text, target, since) {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
out.push(Finding {
source: format!("day-break {name}"),
body: summarize_break(&text).unwrap_or_else(|| head(&text, MAX_LINES)),
});
if out.len() >= 2 {
break;
}
}
}
fn describes_this_run(text: &str, target: &'static Target, since: SystemTime) -> bool {
let (backend, started) = match serde_json::from_str::<serde_json::Value>(text.trim()) {
Ok(v) if v.get("kind").is_some() => (
v.pointer("/day/backend")
.and_then(|b| b.as_str())
.map(str::to_string),
v.pointer("/session/started_at_ms").and_then(|s| s.as_u64()),
),
_ => {
let field = |key: &str| {
text.lines()
.find_map(|l| l.trim().strip_prefix(&format!("{key}=")))
.map(str::to_string)
};
(
field("backend"),
field("started_at_ms").and_then(|v| v.parse().ok()),
)
}
};
if let Some(b) = backend
&& b != target.name
{
return false;
}
let Some(started) = started else {
return true; };
let since_ms = since
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
started + 1000 >= since_ms
}
fn break_store_dir(project: &Project, target: &'static Target, app_id: &str) -> Option<PathBuf> {
match target.kind {
TargetKind::Desktop => {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let slug = slug(app_id);
if cfg!(target_os = "macos") {
Some(
home.join("Library/Application Support")
.join(slug)
.join("day-break"),
)
} else {
Some(home.join(format!(".{slug}")).join("day-break"))
}
}
TargetKind::IosSim => {
let out = crate::ops::output_within(
Command::new("xcrun").args([
"simctl",
"get_app_container",
"booted",
app_id,
"data",
]),
DEVICE_CMD,
)?;
if !out.status.success() {
return None;
}
let container = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!container.is_empty()).then(|| {
Path::new(&container)
.join("Library/Application Support")
.join(slug(app_id))
.join("day-break")
})
}
_ => {
let _ = project;
None
}
}
}
fn os_crash_findings(
project: &Project,
target: &'static Target,
since: SystemTime,
out: &mut Vec<Finding>,
looked: &mut Vec<String>,
) {
match target.kind {
TargetKind::Desktop | TargetKind::IosSim if cfg!(target_os = "macos") => {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return;
};
let dir = home.join("Library/Logs/DiagnosticReports");
looked.push(format!("macOS crash reports ({})", dir.display()));
let stem = process_stem(project, target);
let pid = matches!(target.kind, TargetKind::Desktop)
.then(crate::signals::last_child)
.flatten();
if !wait_for_fresh_ips(&dir, &stem, since, pid) {
crate::ops::status(
"Diagnosis",
"no macOS crash report yet — ReportCrash can take a minute; \
~/Library/Logs/DiagnosticReports will have it",
);
}
for path in newest_files(&dir, 8) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let fresh = std::fs::metadata(&path)
.and_then(|m| m.modified())
.map(|m| m >= since)
.unwrap_or(false);
if !fresh || !name.to_lowercase().starts_with(&stem.to_lowercase()) {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(pid) = pid
&& ips_pid(&text).is_some_and(|reported| reported != pid)
{
continue; }
out.push(Finding {
source: format!("macOS crash report ({name})"),
body: summarize_ips(&text).unwrap_or_else(|| head(&text, MAX_LINES)),
});
break; }
}
TargetKind::Desktop if cfg!(target_os = "linux") => {
looked.push("systemd-coredump (coredumpctl)".into());
let stem = process_stem(project, target);
if let Some(o) = crate::ops::output_within(
Command::new("coredumpctl").args(["info", "--no-pager", &stem]),
DEBUGGER,
) && o.status.success()
{
let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !text.is_empty() {
out.push(Finding {
source: format!("coredumpctl info {stem}"),
body: head(&text, MAX_LINES),
});
return;
}
}
looked.push("a core file beside the app".into());
let core = newest_files(&project.root, 12).into_iter().find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "core" || n.starts_with("core."))
&& modified_since(p, since)
});
match (core, which("gdb")) {
(Some(core), true) => {
if let Some(o) = crate::ops::output_within(
Command::new("gdb")
.args(["-batch", "-ex", "thread apply all bt"])
.arg(process_path(project, target).unwrap_or_default())
.arg(&core),
DEBUGGER,
) {
out.push(Finding {
source: format!("gdb backtrace ({})", core.display()),
body: head(&String::from_utf8_lossy(&o.stdout), MAX_LINES),
});
}
}
(Some(core), false) => out.push(Finding {
source: format!("core file ({})", core.display()),
body: "no gdb on this host to read it — `apt install gdb`, then `gdb -batch -ex bt <exe> <core>`"
.into(),
}),
(None, _) => crate::ops::status(
"Diagnosis",
"no core file — a Linux crash carries no frames without one. In CI: `ulimit -c unlimited` and `sudo sysctl -w kernel.core_pattern=core.%p` before the launch, then re-run.",
),
}
}
TargetKind::Desktop if cfg!(windows) => {
let stem = process_stem(project, target);
let pid = crate::signals::last_child();
let dumps = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.map(|p| p.join("CrashDumps"));
let mut have_dump = false;
if let Some(dir) = dumps.as_ref() {
looked.push(format!("WER local dumps ({})", dir.display()));
for path in newest_files(dir, 8) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mine = name.starts_with(&stem)
|| pid.is_some_and(|p| name.contains(&format!(".{p}.")));
if mine && modified_since(&path, since) {
have_dump = true;
out.push(Finding {
source: format!("minidump ({})", path.display()),
body: format!(
"open in WinDbg/Visual Studio for frames:\n \
windbg -z \"{}\" -c \"!analyze -v; q\"",
path.display()
),
});
}
}
}
looked.push("Windows Application event log (Application Error)".into());
let secs = SystemTime::now()
.duration_since(since)
.map(|d| d.as_secs() + 5)
.unwrap_or(120);
let ps = format!(
"$s=(Get-Date).AddSeconds(-{secs}); \
Get-WinEvent -FilterHashtable @{{LogName='Application'; \
ProviderName='Application Error','Windows Error Reporting','.NET Runtime'; \
StartTime=$s}} -MaxEvents 5 -ErrorAction SilentlyContinue | \
ForEach-Object {{ $_.TimeCreated.ToString('s') + ' ' + $_.ProviderName; \
$_.Message }}"
);
if let Some(o) = crate::ops::output_within(
Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", &ps]),
DEBUGGER,
) {
let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
let mine = text.contains(&stem)
|| pid.is_some_and(|p| {
text.contains(&format!("{p:x}")) || text.contains(&p.to_string())
});
if !text.is_empty() && mine {
out.push(Finding {
source: "Windows Error Reporting (Application event log)".into(),
body: head(&text, MAX_LINES),
});
} else if !have_dump {
crate::ops::status(
"Diagnosis",
"no WER record for this process yet — the event log can lag a few \
seconds behind the exit",
);
}
}
if !have_dump {
crate::ops::status(
"Diagnosis",
"no minidump — Windows writes one only when LocalDumps is enabled. In CI, \
before the launch:\r\n reg add \
\"HKCU\\Software\\Microsoft\\Windows\\Windows Error Reporting\\LocalDumps\" \
/v DumpType /t REG_DWORD /d 2 /f\r\n then re-run; the .dmp lands in \
%LOCALAPPDATA%\\CrashDumps and carries full frames.",
);
}
}
TargetKind::Android => {
looked.push("adb logcat -b crash".into());
let out_cmd = crate::ops::output_within(
Command::new("adb").args(["logcat", "-b", "crash", "-d", "-t", "200"]),
DEVICE_CMD,
);
if let Some(o) = out_cmd
&& o.status.success()
{
let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !text.is_empty() {
out.push(Finding {
source: "android crash buffer (adb logcat -b crash)".into(),
body: head(&text, MAX_LINES),
});
}
}
}
_ => {}
}
}
fn ips_pid(text: &str) -> Option<u32> {
let (_header, body) = text.split_once('\n')?;
serde_json::from_str::<serde_json::Value>(body)
.ok()?
.get("pid")?
.as_u64()
.map(|p| p as u32)
}
fn modified_since(path: &Path, since: SystemTime) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|m| m >= since)
.unwrap_or(false)
}
fn wait_for_fresh_ips(dir: &Path, stem: &str, since: SystemTime, pid: Option<u32>) -> bool {
const BUDGET: std::time::Duration = std::time::Duration::from_secs(30);
let start = std::time::Instant::now();
let stem = stem.to_lowercase();
while start.elapsed() < BUDGET {
let found = newest_files(dir, 8).into_iter().any(|p| {
let name = p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if !name.starts_with(&stem) || !modified_since(&p, since) {
return false;
}
match (
pid,
std::fs::read_to_string(&p)
.ok()
.as_deref()
.and_then(ips_pid),
) {
(Some(want), Some(got)) => want == got,
_ => true,
}
});
if found {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
false
}
fn which(bin: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(bin).is_file()))
}
fn process_path(project: &Project, target: &'static Target) -> Option<PathBuf> {
let stamp = project
.root
.join("build/day/artifacts")
.join(format!("{}-debug.path", target.name));
let path = std::fs::read_to_string(stamp).ok()?.trim().to_string();
(!path.is_empty()).then(|| PathBuf::from(path))
}
fn process_stem(project: &Project, target: &'static Target) -> String {
let name = project.manifest.app.name.clone();
let _ = target;
name
}
fn summarize_ips(text: &str) -> Option<String> {
let (_header, body) = text.split_once('\n')?;
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let s = |key: &str| -> String {
v.get(key)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string()
};
let mut out = vec![format!(
"process {} (pid {}) — {}",
s("procName"),
v.get("pid").and_then(|p| p.as_i64()).unwrap_or(0),
s("captureTime"),
)];
if let Some(e) = v.get("exception") {
out.push(format!(
"exception {}{}",
e.get("type").and_then(|t| t.as_str()).unwrap_or("?"),
e.get("signal")
.and_then(|t| t.as_str())
.map(|sig| format!(" ({sig})"))
.unwrap_or_default(),
));
}
if let Some(ind) = v.pointer("/termination/indicator").and_then(|x| x.as_str()) {
out.push(format!("termination {ind}"));
}
let faulting = v
.get("faultingThread")
.and_then(|f| f.as_u64())
.unwrap_or(0) as usize;
let images: Vec<&str> = v
.get("usedImages")
.and_then(|i| i.as_array())
.map(|a| {
a.iter()
.map(|i| i.get("name").and_then(|n| n.as_str()).unwrap_or("?"))
.collect()
})
.unwrap_or_default();
let frames = v
.get("threads")
.and_then(|t| t.as_array())
.and_then(|t| t.get(faulting))
.and_then(|t| t.get("frames"))
.and_then(|f| f.as_array());
if let Some(frames) = frames {
out.push(format!("faulting thread {faulting}:"));
for f in frames.iter().take(MAX_FRAMES) {
let image = f
.get("imageIndex")
.and_then(|i| i.as_u64())
.and_then(|i| images.get(i as usize).copied())
.unwrap_or("?");
let offset = f.get("imageOffset").and_then(|o| o.as_u64()).unwrap_or(0);
match f.get("symbol").and_then(|s| s.as_str()) {
Some(sym) => {
let at = f
.get("symbolLocation")
.and_then(|l| l.as_u64())
.unwrap_or(0);
out.push(format!(" {image} {sym} + {at}"));
}
None => out.push(format!(" {image} +0x{offset:x}")),
}
}
if frames.len() > MAX_FRAMES {
out.push(format!(" … {} more frame(s)", frames.len() - MAX_FRAMES));
}
}
Some(out.join("\n"))
}
fn summarize_break(text: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
let kind = v.get("kind")?.as_str().unwrap_or("?");
let contained = if v.get("fatal").and_then(|f| f.as_bool()) == Some(false) {
" (contained)"
} else {
""
};
let mut out = vec![format!(
"{kind}{contained} — {} {} on {}",
v.pointer("/app/id").and_then(|x| x.as_str()).unwrap_or("?"),
v.pointer("/app/version")
.and_then(|x| x.as_str())
.unwrap_or("?"),
v.pointer("/day/backend")
.and_then(|x| x.as_str())
.unwrap_or("?"),
)];
for (label, ptr) in [("message", "/message"), ("location", "/location")] {
if let Some(t) = v.pointer(ptr).and_then(|x| x.as_str())
&& !t.is_empty()
{
out.push(format!("{label}: {t}"));
}
}
if let Some(name) = v.pointer("/signal/name").and_then(|n| n.as_str()) {
out.push(format!(
"signal: {name} (code {})",
v.pointer("/signal/code")
.and_then(|c| c.as_i64())
.unwrap_or(0)
));
}
if let Some(uptime) = v.pointer("/session/uptime_ms").and_then(|u| u.as_u64()) {
out.push(format!("died {uptime} ms after launch"));
}
match v.get("backtrace_text").and_then(|b| b.as_str()) {
Some(bt) if !bt.trim().is_empty() => {
out.push("backtrace:".into());
out.push(head(bt, MAX_LINES));
}
_ => out
.push("backtrace: none recorded (the OS crash report below carries the frames)".into()),
}
Some(out.join("\n"))
}
fn slug(app_id: &str) -> String {
let s: String = app_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
if s.is_empty() { "day-break".into() } else { s }
}
fn newest_files(dir: &Path, n: usize) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<(SystemTime, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter_map(|p| {
let t = std::fs::metadata(&p).and_then(|m| m.modified()).ok()?;
Some((t, p))
})
.collect();
files.sort_by_key(|f| std::cmp::Reverse(f.0));
files.into_iter().take(n).map(|(_, p)| p).collect()
}
fn head(text: &str, max: usize) -> String {
let total = text.lines().count();
let mut s: String = text.lines().take(max).collect::<Vec<_>>().join("\n");
if total > max {
s.push_str(&format!("\n… {} more line(s)", total - max));
}
s
}
fn headline_of(body: &str) -> Option<String> {
let keyed = body.lines().find_map(|l| {
let line = l.trim().trim_end_matches(',');
let probe = line.trim_start_matches('"').to_lowercase();
["message", "kind", "exception", "termination"]
.iter()
.any(|k| probe.starts_with(k))
.then(|| line.to_string())
});
let signal = body.lines().find_map(|l| {
let n: i32 = l.trim().strip_prefix("sig=")?.trim().parse().ok()?;
Some(format!("fatal signal {}", signal_name(n)))
});
keyed.or(signal).or_else(|| {
body.lines()
.find(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
})
}
fn signal_name(signo: i32) -> String {
match signo {
4 => "SIGILL".into(),
6 => "SIGABRT".into(),
7 => "SIGBUS".into(),
8 => "SIGFPE".into(),
10 => "SIGBUS".into(),
11 => "SIGSEGV".into(),
_ => format!("signal {signo}"),
}
}
fn indent(body: &str) -> String {
body.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_slug_matches_day_breaks_own_rule() {
assert_eq!(slug("dev.daybrite.showcase"), "dev.daybrite.showcase");
assert_eq!(slug("my app/id"), "my-app-id");
assert_eq!(slug(""), "day-break");
}
#[test]
fn head_says_what_it_dropped() {
let text = (1..=100)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let cut = head(&text, 10);
assert!(cut.starts_with("1\n2\n"));
assert!(cut.ends_with("… 90 more line(s)"));
assert_eq!(head("one\ntwo", 10), "one\ntwo");
}
#[test]
fn the_headline_names_the_crash() {
let report = "{\n \"app_id\": \"dev.x\",\n \"message\": \"intentional abort\",\n}";
assert_eq!(
headline_of(report).unwrap(),
"\"message\": \"intentional abort\""
);
assert_eq!(headline_of("\n\n a fault \nb").unwrap(), "a fault");
}
}