Stability hardening: crash.log + disk-full preflight (2.5)

Two independent improvements from Phase 2.5.

## Panic hook → crash.log

GUI users launched from a .desktop file have no stderr — today panics
vanish without trace. Install a panic::set_hook early in main() that
appends a structured entry to <library_root>/yt-offline.crash.log for
every panic from every thread (UI, axum workers, downloader spawns,
tray bg thread, thumbnail decode workers).

Each entry: ISO-8601 timestamp, thread name, file:line, panic message,
and a backtrace when RUST_BACKTRACE is set. The default panic hook
still runs after ours, so dev workflows keep getting the stderr trace.

Bounded: when the log passes 256 KB the next write rotates it to .1
(one previous generation kept). No external date library — small
civil-time formula in-tree.

## Disk-full preflight

Before yt-dlp ever launches, statvfs the target filesystem. If free
space is under 500 MB, push a synthetic Failed job classified as
DiskFull instead of starting yt-dlp. That way:

- Users see the actual problem ("only 230 MB free on /mnt/…") in the
  Downloads modal with the same "free up space and retry" hint the
  error-classifier emits for mid-download ENOSPC.
- No half-written file lands on disk.
- No 30-second yt-dlp warmup wasted on a doomed run.

statvfs returning None (non-Unix, missing path) skips the check —
better to let yt-dlp run than to refuse on no data.

5 new tests: 3 in crash (civil-time, log shape, real-panic capture),
2 in disk_space (statvfs plumbing + missing-path None). 74 pass total.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-27 02:53:56 -07:00
parent 55b90a22b6
commit c1bd88d800
6 changed files with 400 additions and 5 deletions

View file

@ -329,6 +329,34 @@ impl Downloader {
// Per-platform download archive keeps cross-platform IDs from colliding
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
let _ = std::fs::create_dir_all(&platform_dir);
// Disk-full preflight. Refuse to spawn yt-dlp when the target
// filesystem has less than the floor of free space — that's a
// near-certain in-progress ENOSPC, which leaves a partial file
// and a confusing failure. We surface it as a synthetic Failed
// job classified as DiskFull so it appears in the Downloads
// panel alongside other classified errors instead of vanishing.
//
// statvfs returning None (non-Unix host, missing path) skips the
// check — better to let yt-dlp run and produce a real error
// than refuse on missing data.
if let Some(free) = crate::disk_space::available_bytes(&self.channels_root) {
if free < crate::disk_space::FREE_SPACE_FLOOR_BYTES {
self.push_synthetic_failure(
url,
platform_dir.display().to_string(),
crate::error_class::ErrorClass::DiskFull,
format!(
"only {} free on {} (need at least {})",
crate::disk_space::fmt_bytes(free),
self.channels_root.display(),
crate::disk_space::fmt_bytes(crate::disk_space::FREE_SPACE_FLOOR_BYTES),
),
);
return;
}
}
let archive_path = platform_dir.join("archive.txt");
let platform_label = info.platform.dir_name();
@ -619,6 +647,36 @@ impl Downloader {
});
}
/// Push a job that was never going to start — used for preflight
/// failures (currently just disk-full). Constructs a closed channel
/// so the immediate `Job::drain` sees no progress, and seeds the log
/// with the human-readable reason so the UI's last_line + the error
/// hint together explain what went wrong.
fn push_synthetic_failure(
&mut self,
url: String,
label: String,
class: crate::error_class::ErrorClass,
reason: String,
) {
let (_tx, rx) = std::sync::mpsc::channel::<Msg>();
// _tx is dropped here, so any subsequent drain hits the
// "channel closed" branch and stays still. We start `state` as
// Failed directly rather than running it through the Finished
// transition.
let mut log = VecDeque::new();
log.push_back(format!("[preflight] {reason}"));
self.jobs.push(Job {
url,
label,
state: JobState::Failed,
progress: 0.0,
log,
failure_class: Some(class),
rx,
});
}
/// Drain pending messages from all job threads and promote queued jobs.
///
/// Call this regularly from the UI event loop to pick up progress updates.