From a1a979ae03d32d6731b8a7ac61bdfc562bb3ebd8 Mon Sep 17 00:00:00 2001 From: DarkBreakpoint Date: Tue, 1 Sep 2026 13:37:08 -0500 Subject: [PATCH] fix(dvr): an interrupted live capture fails instead of restarting Task 12, step 2. On restart every unfinished task was requeued, live captures included. A live recording is a window on a broadcast: whatever played while the process was down is gone. Requeueing restarted the capture from the current moment and committed it as a normal recording, silently missing everything up to the restart, with nothing in the result to say so. Live in `Running` or `WaitingForCapacity` now becomes `Failed` with a reason. A future `Scheduled` window is untouched, because it has not started and can still run in full. VOD and series still resume -- the file on the provider is still there, which is the whole difference. Task 14 needed one test rather than code. Its matrix, the No-Auth parity, and the stale-token rejection are already implemented and covered: `builtin_admin_claims` resolves `builtin:admin` with ROLE_ADMIN, PERM_ALL and the current schema, and `validate_token_claims` refuses an older permission schema before any bit is read. The clause with no test was "physical deletion with positive references is denied even to Admin". That now has one, and it is worth stating precisely: permission decides whether an entry may be removed, not whether the bytes go. The reference rule takes no principal at all, so an administrator removing their own entry leaves another user's recording on disk. --- .../dvr/src/recording/recording_deletion.rs | 32 +++++++++++++ backend/dvr/src/recording/recording_queue.rs | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/backend/dvr/src/recording/recording_deletion.rs b/backend/dvr/src/recording/recording_deletion.rs index 8e6230953..94d60e06d 100644 --- a/backend/dvr/src/recording/recording_deletion.rs +++ b/backend/dvr/src/recording/recording_deletion.rs @@ -936,6 +936,38 @@ mod tests { assert!(!final_path.exists(), "the recording the first deletion claimed is the one removed"); } + #[tokio::test] + async fn even_a_principal_allowed_to_delete_cannot_unlink_a_referenced_file() { + // Task 14: authorization decides whether an entry may be removed. It + // does not decide whether the bytes go -- that is the reference rule, + // and it takes no principal at all. An admin or the retention + // supervisor removing their entry must still leave another user's + // recording on disk. + let dir = TempDir::new().expect("tempdir"); + let queue = RecordingQueue::new_persistent(dir.path(), dir.path()).expect("open recording repository"); + let shared_file = dir.path().join("programme.ts"); + std::fs::write(&shared_file, b"recorded bytes").expect("write"); + for (uuid, owner) in [("admin-entry", "builtin:admin"), ("bob-entry", "web:bob")] { + let mut task = finished_with_state(uuid, RecordingTaskState::Completed, None); + task.file_path.clone_from(&shared_file); + task.recording.owner = RecordingOwner::User(UserId::from(owner)); + let persisted = RecordingQueue::to_persisted(&task); + mutate(&queue, move |candidate| { + candidate.finished.push(persisted.clone()); + Ok(()) + }) + .await + .expect("seed"); + } + + // The permit says yes, as it would for an administrator. + let target = begin_deletion_authorized(&queue, "admin-entry", |_| true).await.expect("permitted"); + assert!(target.still_referenced); + assert_eq!(target.path_to_unlink(), None, "permission does not override a live reference"); + execute_deletion_target(&target).await.expect("execute"); + assert!(shared_file.exists(), "Bob's recording survives an administrator removing their own entry"); + } + #[tokio::test] async fn a_sole_entry_still_removes_its_file() { let dir = TempDir::new().expect("tempdir"); diff --git a/backend/dvr/src/recording/recording_queue.rs b/backend/dvr/src/recording/recording_queue.rs index afdc949d9..840a682b4 100644 --- a/backend/dvr/src/recording/recording_queue.rs +++ b/backend/dvr/src/recording/recording_queue.rs @@ -43,6 +43,7 @@ fn poisoned_repository() -> std::io::Error { } const RECORDING_WINDOW_EXPIRED_ERR: &str = "Recording window already expired"; +const RECORDING_INTERRUPTED_ERR: &str = "Recording was interrupted by a restart and cannot be resumed"; static RECORDING_TASK_ID_COUNTER: AtomicU64 = AtomicU64::new(1); /// Reason a persisted entry cannot be converted back to its in-memory form @@ -1101,6 +1102,19 @@ impl RecordingQueue { task.finished = false; return task; } + // A live capture cannot be picked up again: whatever was broadcast while + // the process was down is gone. Requeueing would restart it and write a + // recording silently missing everything up to now. + if task.kind == RecordingKind::Live + && matches!(task.state, RecordingTaskState::Running | RecordingTaskState::WaitingForCapacity) + { + task.paused = false; + task.finished = true; + task.state = RecordingTaskState::Failed; + task.error = Some(RECORDING_INTERRUPTED_ERR.to_string()); + task.next_retry_at = None; + return task; + } if !task.finished { task.paused = false; task.state = RecordingTaskState::Queued; @@ -1732,6 +1746,40 @@ mod tests { let _ = std::fs::remove_dir_all(state_file); } + #[test] + fn an_interrupted_live_capture_fails_instead_of_restarting() { + // The broadcast that played while the process was down is gone. A + // resumed live capture would write a recording with a hole at the + // front and report it as complete. + for state in [RecordingTaskState::Running, RecordingTaskState::WaitingForCapacity] { + let interrupted = RecordingTask { state, ..task("live", RecordingKind::Live, state) }; + let recovered = RecordingQueue::recover_loaded_task(interrupted); + assert_eq!(recovered.state, RecordingTaskState::Failed, "live in {}", state.label()); + assert!(recovered.finished); + assert!(recovered.error.is_some(), "the operator is told why"); + } + } + + #[test] + fn an_interrupted_transfer_is_still_resumed() { + // The counterpart: a file on a server is still there after a restart, + // so VOD and series pick up where they left off. + for kind in [RecordingKind::Vod, RecordingKind::Series] { + let interrupted = task("film", kind, RecordingTaskState::Running); + let recovered = RecordingQueue::recover_loaded_task(interrupted); + assert_eq!(recovered.state, RecordingTaskState::Queued, "{kind} must resume"); + assert!(!recovered.finished); + } + } + + #[test] + fn a_future_live_recording_survives_a_restart_still_scheduled() { + let scheduled = task("live", RecordingKind::Live, RecordingTaskState::Scheduled); + let recovered = RecordingQueue::recover_loaded_task(scheduled); + assert_eq!(recovered.state, RecordingTaskState::Scheduled, "a window that has not opened is untouched"); + assert!(!recovered.finished); + } + #[test] fn recover_loaded_task_requeues_waiting_states() { let waiting_for_capacity = RecordingTask {