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:
parent
55b90a22b6
commit
c1bd88d800
6 changed files with 400 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue