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
89
src/disk_space.rs
Normal file
89
src/disk_space.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Free-space query for the download preflight check.
|
||||
//!
|
||||
//! Rust stdlib doesn't have `available_space` on stable, so we call
|
||||
//! `statvfs(3)` via libc on Unix. On non-Unix the function returns
|
||||
//! `None`, which the caller treats as "skip the check" — we'd rather
|
||||
//! let yt-dlp run and surface a real ENOSPC error than refuse to start
|
||||
//! based on no data.
|
||||
|
||||
/// Return the bytes of free space available to the calling user on the
|
||||
/// filesystem holding `path`. `None` means the query failed (path missing,
|
||||
/// non-Unix host, libc error) and the caller should skip the preflight.
|
||||
///
|
||||
/// Uses `f_frsize * f_bavail` — `f_bavail` is the "available to
|
||||
/// unprivileged user" count which already excludes the reserved-root
|
||||
/// blocks; that's the number a non-root download cares about.
|
||||
#[cfg(unix)]
|
||||
pub fn available_bytes(path: &std::path::Path) -> Option<u64> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: libc::statvfs reads a valid C string and writes into a
|
||||
// zero-initialised libc::statvfs we own. Returning 0 on success is
|
||||
// documented; we check that before reading the fields. mem::zeroed()
|
||||
// is safe for plain-data libc structs.
|
||||
let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut buf) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// f_frsize is the underlying block size (may differ from f_bsize on
|
||||
// some filesystems). f_bavail is in those blocks. Cast both to u64
|
||||
// since libc declares them as platform-dependent types.
|
||||
let frsize = buf.f_frsize as u64;
|
||||
let bavail = buf.f_bavail as u64;
|
||||
Some(frsize.saturating_mul(bavail))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn available_bytes(_path: &std::path::Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Floor below which we refuse to start a new download. Picked at the low
|
||||
/// end of a "typical 1080p YouTube video" range — most videos are smaller
|
||||
/// than this, but starting with less than 500 MB free is a near-certain
|
||||
/// path to an aborted job and a half-written file. Configurable later if
|
||||
/// a user complains; for now a fixed sane default is enough.
|
||||
pub const FREE_SPACE_FLOOR_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
/// Format a byte count for human display. Duplicates a similar helper in
|
||||
/// app.rs because the modules can't easily share one (app.rs depends on
|
||||
/// eframe). 3 GB / 250 MB / 15 KB-style rounding.
|
||||
pub fn fmt_bytes(bytes: u64) -> String {
|
||||
if bytes >= 1_073_741_824 {
|
||||
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
||||
} else if bytes >= 1_048_576 {
|
||||
format!("{} MB", bytes / 1_048_576)
|
||||
} else if bytes >= 1024 {
|
||||
format!("{} KB", bytes / 1024)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn root_path_has_some_free_space() {
|
||||
// / always exists on Unix; if statvfs returns Some(_) at all,
|
||||
// the query plumbing works. We don't assert a specific number
|
||||
// because CI runners' disks vary.
|
||||
let bytes = available_bytes(std::path::Path::new("/"));
|
||||
assert!(bytes.is_some(), "statvfs(/) should succeed on Unix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn missing_path_returns_none() {
|
||||
// statvfs on a nonexistent path returns -1; we surface that as
|
||||
// None so the caller skips the preflight rather than refusing
|
||||
// every download.
|
||||
let bytes = available_bytes(std::path::Path::new("/this/path/should/not/exist/anywhere"));
|
||||
assert!(bytes.is_none());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue