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.
This commit is contained in:
DarkBreakpoint
2026-09-01 13:37:08 -05:00
parent 3606257439
commit a1a979ae03
2 changed files with 80 additions and 0 deletions
@@ -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");
@@ -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 {