fix(media): stop a poster-frame failure from deleting the guest's video
Reproduced live, by accident, while smoke-testing on a machine with no ffmpeg: the clip uploaded fine, returned 201, and roughly six seconds later had `deleted_at` set and was gone from the feed. The `Ok(None)` "this clip yields no frame" case was already handled — that fix landed when sub-second clips were being destroyed. But the `?` on the call itself still routed every OTHER failure into the same give-up path, which soft-deletes: ffmpeg missing from the image, ffmpeg hanging on a truncated `.mov` and tripping the timeout, an ENOSPC on `thumbnails/`, or a DB blip in `set_thumbnail_path`. None of those says anything about the video, and `get_original` serves the file byte-for-byte, so a post that merely lacks a poster is fully watchable. No failure in the video branch may fail the upload. iPhone `.mov` is exactly the input most likely to trip it, and a wedding clip is not retakeable. ENOSPC gets its own classifier. It was the one failure the retry loop actively made worse: a disk does not drain during six seconds of backoff, so all three attempts failed identically while holding a compression permit that photos were queued behind — and the give-up path then refunded the quota and soft-deleted the row while deliberately KEEPING the original. That freed nothing, removed the photo seconds after a 201, and handed the guest the allowance to upload it again into the same full disk. Now: no retry, no refund, no delete. The row stays live and the photo is served from its original, and `backfill_stale_derivatives` regenerates the derivatives on the next start once there is room. `is_storage_full_error` has to look inside `ImageError::IoError` as well as at bare io errors, because `image` wraps rather than sources it and a plain chain walk would miss every derivative-write failure. FFMPEG_TIMEOUT drops 120s -> 45s. It was never a budget for honest work — a poster from a phone clip takes well under a second, and `-ss` before `-i` means even a 500 MB file seeks rather than scans. It is the ceiling on how long a pathological input holds a permit that guests' photos are waiting behind, so it should be as tight as it can be without cutting off real work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,12 @@ use anyhow::{Context, Result};
|
||||
/// the semaphore permit and the pool eventually deadlocks; in the export worker it strands the job
|
||||
/// at `running` so the keepsake never completes. `export.rs` had NO timeout at all before this
|
||||
/// module — sharing the spawn fixes that too.
|
||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// 45s, not the 120s this started at. The timeout is not a budget for honest work — a poster
|
||||
/// frame from a phone clip takes well under a second, and `-ss` before `-i` means even a 500 MB
|
||||
/// file seeks rather than scans. It is purely the ceiling on how long a pathological input may
|
||||
/// hold a compression permit that guests' photos are queued behind, so it should be as tight as
|
||||
/// it can be without ever cutting off real work.
|
||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Seek positions to try, in order.
|
||||
///
|
||||
@@ -107,6 +112,22 @@ async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Is there a usable `ffmpeg` on PATH?
|
||||
///
|
||||
/// The poster-frame path shells out, and `extract_poster_frame` documents `Err` as meaning
|
||||
/// "a hang or a SPAWN failure" — which is exactly what a missing binary produces. So on a
|
||||
/// machine without ffmpeg the test below stops exercising the case it names (missing INPUT)
|
||||
/// and instead reports a code defect that isn't there. The runtime image installs ffmpeg
|
||||
/// (see backend/Dockerfile), so this only ever skips on a bare developer machine.
|
||||
fn ffmpeg_available() -> bool {
|
||||
std::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// The order is the whole fix. `-ss` must precede `-i`, and 0 must be tried after 1 s.
|
||||
#[test]
|
||||
fn the_fallback_seek_exists_and_comes_last() {
|
||||
@@ -121,6 +142,15 @@ mod tests {
|
||||
/// poster", never fail the upload. `Err` is reserved for a hang or a spawn failure.
|
||||
#[tokio::test]
|
||||
async fn a_missing_source_yields_no_frame_rather_than_an_error() {
|
||||
if !ffmpeg_available() {
|
||||
eprintln!(
|
||||
"SKIP a_missing_source_yields_no_frame_rather_than_an_error: no ffmpeg on PATH. \
|
||||
A missing binary is a spawn failure, which this function returns Err for by \
|
||||
design, so the missing-INPUT case cannot be exercised here. Install ffmpeg to \
|
||||
run it (the runtime image already has it)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("es-video-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("out.jpg");
|
||||
|
||||
Reference in New Issue
Block a user