Skip to main content

eoka_server/
recordings.rs

1use parking_lot::Mutex;
2use std::collections::HashMap;
3use std::fmt::Write;
4use std::future::Future;
5use std::sync::Arc;
6use std::time::Duration;
7
8use eoka::{NativeRecording, NativeRecordingOptions, Page, RecordingError};
9use eoka_protocol::sidecar::{
10    ArtifactContainer, ErrorCode, RecordingFailure, RecordingFailureStage, RecordingIdParams,
11    RecordingRecord, RecordingStartParams, RecordingState, ServerError,
12};
13use serde_json::Value;
14use tokio::io::AsyncWriteExt;
15use tokio::sync::watch;
16
17use crate::artifacts::{FileFailure, StagedArtifact};
18
19mod mp4;
20
21const MAX_SESSION_RECORDINGS: usize = 1024;
22const MAX_SESSION_RECORDING_PARAMETER_BYTES: usize = 16 * 1024 * 1024;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum Control {
26    Run,
27    Stop,
28    Cancel,
29}
30
31struct EntryState {
32    record: RecordingRecord,
33    admitted: bool,
34    started: bool,
35    cancelled: bool,
36    publishing: bool,
37    partial_path: Option<String>,
38}
39
40struct Entry {
41    params: RecordingStartParams,
42    state: Mutex<EntryState>,
43    changed: watch::Sender<u64>,
44    control: watch::Sender<Control>,
45}
46
47fn terminal(state: RecordingState) -> bool {
48    matches!(
49        state,
50        RecordingState::Completed | RecordingState::Cancelled | RecordingState::Failed
51    )
52}
53
54impl Entry {
55    fn update(&self, apply: impl FnOnce(&mut EntryState)) {
56        let mut state = self.state.lock();
57        apply(&mut state);
58        drop(state);
59        self.changed
60            .send_modify(|revision| *revision = revision.wrapping_add(1));
61    }
62
63    fn snapshot(&self) -> RecordingRecord {
64        self.state.lock().record.clone()
65    }
66    fn is_terminal(&self) -> bool {
67        terminal(self.state.lock().record.state)
68    }
69    fn partial_path(&self) -> Option<String> {
70        self.state.lock().partial_path.clone()
71    }
72
73    fn retain_partial_path(&self, path: String) {
74        self.update(|state| {
75            if let Some(failure) = &mut state.record.failure {
76                failure.partial_path = Some(path.clone());
77            }
78            state.partial_path = Some(path);
79        });
80    }
81
82    fn deadline_failure(&self, message: &str) -> RecordingFailure {
83        let state = self.state.lock();
84        RecordingFailure {
85            stage: if state.publishing {
86                RecordingFailureStage::Publish
87            } else {
88                RecordingFailureStage::Cancel
89            },
90            code: ErrorCode::RecordingFailed,
91            message: if state.publishing {
92                format!(
93                    "{message}; publication is in flight and may still complete at {}",
94                    state.record.path
95                )
96            } else {
97                message.to_owned()
98            },
99            partial_path: state.partial_path.clone(),
100            cleanup_uncertain: Some(true),
101        }
102    }
103
104    fn begin_publication(&self) -> bool {
105        let mut state = self.state.lock();
106        if state.publishing || state.cancelled || terminal(state.record.state) {
107            return false;
108        }
109        state.publishing = true;
110        true
111    }
112
113    fn finish_publication(&self, result: Result<eoka_protocol::sidecar::Artifact, FileFailure>) {
114        self.update(|state| {
115            state.publishing = false;
116            if terminal(state.record.state) {
117                if let Some(failure) = &mut state.record.failure {
118                    match result {
119                        Ok(artifact) => {
120                            let _ = write!(failure.message, "; publication later completed at {} ({} bytes); no completed outcome was acknowledged", artifact.path, artifact.bytes);
121                            failure.partial_path = None;
122                            state.partial_path = None;
123                        },
124                        Err(error) => {
125                            failure.message.push_str("; publication later failed: ");
126                            failure.message.push_str(&error.message);
127                            failure.partial_path = error.partial_path;
128                        },
129                    }
130                }
131                return;
132            }
133            match result {
134                Ok(artifact) => {
135                    state.record.state = RecordingState::Completed;
136                    state.record.artifact = Some(artifact);
137                    state.record.failure = None;
138                    state.partial_path = None;
139                },
140                Err(error) => {
141                    state.record.state = RecordingState::Failed;
142                    state.record.failure = Some(file_failure(error));
143                },
144            }
145        });
146    }
147
148    async fn wait(&self, admission: bool) -> RecordingRecord {
149        let mut changed = self.changed.subscribe();
150        loop {
151            {
152                let state = self.state.lock();
153                if (admission && state.admitted) || terminal(state.record.state) {
154                    return state.record.clone();
155                }
156            }
157            if changed.changed().await.is_err() {
158                return self.snapshot();
159            }
160        }
161    }
162
163    fn request(&self, cancel: bool) {
164        self.update(|state| {
165            if terminal(state.record.state) {
166                return;
167            }
168            state.record.state = RecordingState::Finalizing;
169            state.admitted = true;
170            if cancel {
171                state.cancelled = true;
172                self.control.send_replace(Control::Cancel);
173                if !state.started {
174                    state.record.state = RecordingState::Cancelled;
175                    state.record.failure = Some(RecordingFailure {
176                        stage: RecordingFailureStage::Cancel,
177                        code: ErrorCode::Cancelled,
178                        message: "recording cancelled before native admission".to_owned(),
179                        partial_path: None,
180                        cleanup_uncertain: None,
181                    });
182                }
183            } else if !state.cancelled {
184                self.control.send_replace(Control::Stop);
185            }
186        });
187    }
188
189    fn fail(&self, failure: RecordingFailure) {
190        self.update(|state| {
191            if terminal(state.record.state) {
192                if let (Some(existing), Some(path)) =
193                    (&mut state.record.failure, failure.partial_path)
194                {
195                    existing.partial_path = Some(path.clone());
196                    state.partial_path = Some(path);
197                }
198                return;
199            }
200            state.admitted = true;
201            state.record.state = RecordingState::Failed;
202            state.record.failure = Some(failure);
203        });
204    }
205
206    fn error(&self, code: ErrorCode, message: impl Into<String>) -> ServerError {
207        let mut error = ServerError::new(code, message);
208        let data = error.data.get_or_insert_with(Default::default);
209        data.recording_id = Some(self.params.recording_id.clone());
210        data.path = Some(self.params.path.clone());
211        data.recording = Some(self.snapshot());
212        error
213    }
214}
215
216#[derive(Default)]
217struct RecordingHistory {
218    entries: HashMap<String, Arc<Entry>>,
219    retained_parameter_bytes: usize,
220}
221
222#[derive(Clone, Default)]
223pub struct RecordingManager {
224    history: Arc<Mutex<RecordingHistory>>,
225}
226
227impl RecordingManager {
228    fn entry(&self, id: &str) -> Result<Arc<Entry>, ServerError> {
229        let entry = self.history.lock().entries.get(id).cloned();
230        entry.ok_or_else(|| {
231            let mut error = ServerError::new(
232                ErrorCode::RecordingNotFound,
233                format!("unknown recording ID: {id}"),
234            );
235            error.data.get_or_insert_with(Default::default).recording_id = Some(id.to_owned());
236            error
237        })
238    }
239
240    fn reserve(&self, params: RecordingStartParams) -> Result<Arc<Entry>, ServerError> {
241        let mut history = self.history.lock();
242        if let Some(entry) = history.entries.get(&params.recording_id).cloned() {
243            drop(history);
244            if entry.params != params {
245                return Err(entry.error(
246                    ErrorCode::RecordingConflict,
247                    "recording ID already belongs to different start parameters",
248                ));
249            }
250            return Ok(entry);
251        }
252        let retained_parameter_bytes = params
253            .recording_id
254            .len()
255            .checked_add(params.page_id.len())
256            .and_then(|bytes| bytes.checked_add(params.path.len()))
257            .and_then(|bytes| bytes.checked_add(history.retained_parameter_bytes))
258            .filter(|bytes| *bytes <= MAX_SESSION_RECORDING_PARAMETER_BYTES);
259        let capacity_limit = if history.entries.len() >= MAX_SESSION_RECORDINGS {
260            Some(format!(
261                "session recording history limit of {MAX_SESSION_RECORDINGS} distinct IDs reached"
262            ))
263        } else if retained_parameter_bytes.is_none() {
264            Some(format!(
265                "session recording history limit of {MAX_SESSION_RECORDING_PARAMETER_BYTES} retained start-parameter bytes exceeded"
266            ))
267        } else {
268            None
269        };
270        if let Some(limit) = capacity_limit {
271            let mut error = ServerError::new(
272                ErrorCode::ServerBusy,
273                format!(
274                    "{limit}; completed, cancelled, and failed recordings retain their slots and parameter bytes for the sidecar lifetime; start a new Browser/session to reset recording history"
275                ),
276            );
277            let data = error.data.get_or_insert_with(Default::default);
278            data.recording_id = Some(params.recording_id);
279            data.path = Some(params.path);
280            return Err(error);
281        }
282        let (changed, _) = watch::channel(0);
283        let (control, _) = watch::channel(Control::Run);
284        let entry = Arc::new(Entry {
285            state: Mutex::new(EntryState {
286                record: RecordingRecord {
287                    recording_id: params.recording_id.clone(),
288                    page_id: params.page_id.clone(),
289                    state: RecordingState::Running,
290                    path: params.path.clone(),
291                    artifact: None,
292                    failure: None,
293                },
294                admitted: false,
295                started: false,
296                cancelled: false,
297                publishing: false,
298                partial_path: None,
299            }),
300            params,
301            changed,
302            control,
303        });
304        history.retained_parameter_bytes =
305            retained_parameter_bytes.expect("recording parameter bytes passed admission");
306        history
307            .entries
308            .insert(entry.params.recording_id.clone(), entry.clone());
309        Ok(entry)
310    }
311
312    pub fn admit(&self, params: RecordingStartParams) -> Result<(), ServerError> {
313        params.validate()?;
314        self.reserve(params)?;
315        Ok(())
316    }
317
318    pub async fn start(
319        &self,
320        params: RecordingStartParams,
321        page: Result<Page, ServerError>,
322    ) -> Result<RecordingRecord, ServerError> {
323        params.validate()?;
324        let entry = self.reserve(params)?;
325        let fresh = {
326            let mut state = entry.state.lock();
327            if state.started || terminal(state.record.state) {
328                false
329            } else {
330                state.started = true;
331                true
332            }
333        };
334        if !fresh {
335            return Ok(entry.wait(true).await);
336        }
337        let worker_entry = entry.clone();
338        tokio::spawn(async move {
339            run(worker_entry, page).await;
340        });
341        let record = entry.wait(true).await;
342        if record.state == RecordingState::Failed {
343            let failure = record
344                .failure
345                .as_ref()
346                .expect("failed recording has failure evidence");
347            return Err(entry.error(failure.code, failure.message.clone()));
348        }
349        Ok(record)
350    }
351
352    pub async fn get(&self, id: &str) -> Result<RecordingRecord, ServerError> {
353        Ok(self.entry(id)?.wait(true).await)
354    }
355
356    pub async fn stop(&self, id: &str) -> Result<RecordingRecord, ServerError> {
357        let entry = self.entry(id)?;
358        entry.request(false);
359        Ok(entry.wait(false).await)
360    }
361
362    pub async fn cancel(&self, id: &str) -> Result<RecordingRecord, ServerError> {
363        let entry = self.entry(id)?;
364        entry.request(true);
365        Ok(entry.wait(false).await)
366    }
367
368    pub async fn cancel_page(&self, page_id: &str) -> Result<(), ServerError> {
369        let entries: Vec<_> = self
370            .history
371            .lock()
372            .entries
373            .values()
374            .filter(|entry| entry.params.page_id == page_id && !entry.is_terminal())
375            .cloned()
376            .collect();
377        self.cancel_entries(entries).await
378    }
379
380    pub fn request_shutdown(&self) {
381        for entry in self.history.lock().entries.values() {
382            entry.request(true);
383        }
384    }
385
386    pub fn shutdown_timeout_error(&self) -> Option<ServerError> {
387        let entries: Vec<_> = self.history.lock().entries.values().cloned().collect();
388        let mut failure = None;
389        for entry in entries {
390            entry.request(true);
391            if !entry.is_terminal() {
392                entry.fail(entry.deadline_failure(
393                    "recording cleanup was not acknowledged before browser shutdown",
394                ));
395            }
396            if failure.is_none() {
397                let detail = {
398                    let state = entry.state.lock();
399                    state
400                        .record
401                        .failure
402                        .as_ref()
403                        .filter(|detail| detail.cleanup_uncertain == Some(true))
404                        .map(|detail| (detail.code, detail.message.clone()))
405                };
406                if let Some((code, message)) = detail {
407                    failure = Some(entry.error(code, message));
408                }
409            }
410        }
411        failure
412    }
413
414    pub async fn shutdown(&self) -> Result<(), ServerError> {
415        let entries: Vec<_> = self
416            .history
417            .lock()
418            .entries
419            .values()
420            .filter(|entry| !entry.is_terminal())
421            .cloned()
422            .collect();
423        self.cancel_entries(entries).await
424    }
425
426    async fn cancel_entries(&self, entries: Vec<Arc<Entry>>) -> Result<(), ServerError> {
427        for entry in &entries {
428            entry.request(true);
429        }
430        let mut failure = None;
431        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
432        for entry in entries {
433            match tokio::time::timeout_at(deadline, entry.wait(false)).await {
434                Ok(record) if record.state == RecordingState::Failed => {
435                    if let Some(detail) = record.failure {
436                        failure.get_or_insert_with(|| entry.error(detail.code, detail.message));
437                    }
438                }
439                Ok(_) => {}
440                Err(_) => {
441                    entry.fail(
442                        entry.deadline_failure("recording cleanup exceeded the shutdown deadline"),
443                    );
444                    failure.get_or_insert_with(|| {
445                        entry.error(
446                            ErrorCode::RecordingFailed,
447                            "recording cleanup exceeded the shutdown deadline",
448                        )
449                    });
450                }
451            }
452        }
453        match failure {
454            Some(error) => Err(error),
455            None => Ok(()),
456        }
457    }
458}
459
460pub fn supports_control(method: &str) -> bool {
461    matches!(
462        method,
463        "recording.get" | "recording.stop" | "recording.cancel"
464    )
465}
466
467pub async fn control(
468    manager: &RecordingManager,
469    method: &str,
470    params: Value,
471) -> Result<Value, ServerError> {
472    let params: RecordingIdParams = crate::methods::parse_params(params)?;
473    params.validate()?;
474    let record = match method {
475        "recording.get" => manager.get(&params.recording_id).await?,
476        "recording.stop" => manager.stop(&params.recording_id).await?,
477        "recording.cancel" => manager.cancel(&params.recording_id).await?,
478        _ => return Err(ServerError::unknown_method(method)),
479    };
480    serde_json::to_value(record).map_err(|error| ServerError::internal(error.to_string()))
481}
482
483fn native_failure(
484    stage: RecordingFailureStage,
485    error: RecordingError,
486    partial: Option<String>,
487) -> RecordingFailure {
488    let code = match &error {
489        RecordingError::Unsupported(_) => ErrorCode::RecordingUnsupported,
490        RecordingError::AlreadyActive => ErrorCode::RecordingAlreadyActive,
491        RecordingError::InvalidOptions(_) => ErrorCode::InvalidParams,
492        RecordingError::InvalidState(_) | RecordingError::Precondition(_) => {
493            ErrorCode::RecordingInvalidState
494        }
495        _ => ErrorCode::RecordingFailed,
496    };
497    let cleanup_uncertain = matches!(error, RecordingError::CleanupUncertain(_)).then_some(true);
498    RecordingFailure {
499        stage,
500        code,
501        message: error.to_string(),
502        partial_path: partial,
503        cleanup_uncertain,
504    }
505}
506
507fn file_failure(error: FileFailure) -> RecordingFailure {
508    RecordingFailure {
509        stage: error.stage,
510        code: ErrorCode::FileOutput,
511        message: error.message,
512        partial_path: error.partial_path,
513        cleanup_uncertain: None,
514    }
515}
516
517async fn cancellation(control: &mut watch::Receiver<Control>) {
518    loop {
519        if *control.borrow_and_update() == Control::Cancel {
520            return;
521        }
522        if control.changed().await.is_err() {
523            return;
524        }
525    }
526}
527
528async fn cleanup(
529    native: Option<&NativeRecording>,
530    stage: &mut StagedArtifact,
531    mut failure: Option<RecordingFailure>,
532) -> Option<RecordingFailure> {
533    if let Some(native) = native {
534        let result = tokio::time::timeout(Duration::from_secs(3), native.cancel()).await;
535        let message = match result {
536            Ok(Ok(())) => None,
537            Ok(Err(error)) => Some(error.to_string()),
538            Err(_) => Some("native recorder cleanup exceeded its deadline".to_owned()),
539        };
540        if let Some(message) = message {
541            let detail = failure.get_or_insert_with(|| RecordingFailure {
542                stage: RecordingFailureStage::Cancel,
543                code: ErrorCode::RecordingFailed,
544                message: String::new(),
545                partial_path: Some(stage.partial.to_string_lossy().into_owned()),
546                cleanup_uncertain: None,
547            });
548            if !detail.message.is_empty() {
549                detail.message.push_str("; ");
550            }
551            detail.message.push_str("cleanup failed: ");
552            detail.message.push_str(&message);
553            detail.cleanup_uncertain = Some(true);
554        }
555    }
556    match tokio::time::timeout(Duration::from_secs(2), stage.abandon()).await {
557        Ok(Ok(())) => {}
558        result => {
559            let message = match result {
560                Ok(Err(error)) => error.message,
561                _ => "local file cleanup exceeded its deadline".to_owned(),
562            };
563            let detail = failure.get_or_insert_with(|| RecordingFailure {
564                stage: RecordingFailureStage::Close,
565                code: ErrorCode::FileOutput,
566                message: String::new(),
567                partial_path: Some(stage.partial.to_string_lossy().into_owned()),
568                cleanup_uncertain: None,
569            });
570            if !detail.message.is_empty() {
571                detail.message.push_str("; ");
572            }
573            detail.message.push_str(&message);
574            detail.cleanup_uncertain = Some(true);
575        }
576    }
577    failure
578}
579
580async fn finish_cancel(
581    entry: &Entry,
582    native: Option<&NativeRecording>,
583    stage: &mut StagedArtifact,
584    failure: Option<RecordingFailure>,
585) {
586    match cleanup(native, stage, failure).await {
587        Some(failure) => entry.fail(failure),
588        None => entry.update(|state| {
589            if terminal(state.record.state) {
590                return;
591            }
592            state.admitted = true;
593            state.record.state = RecordingState::Cancelled;
594            state.record.failure = Some(RecordingFailure {
595                stage: RecordingFailureStage::Cancel,
596                code: ErrorCode::Cancelled,
597                message: "recording cancelled; partial file was not published".to_owned(),
598                partial_path: Some(stage.partial.to_string_lossy().into_owned()),
599                cleanup_uncertain: None,
600            });
601        }),
602    }
603}
604
605async fn run(entry: Arc<Entry>, page: Result<Page, ServerError>) {
606    let page = match page {
607        Ok(page) => page,
608        Err(error) => {
609            entry.fail(RecordingFailure {
610                stage: RecordingFailureStage::Start,
611                code: error.code,
612                message: error.message,
613                partial_path: None,
614                cleanup_uncertain: None,
615            });
616            return;
617        }
618    };
619    let mut stage = match StagedArtifact::prepare(&entry.params.path).await {
620        Ok(stage) => stage,
621        Err(error) => {
622            entry.fail(file_failure(error));
623            return;
624        }
625    };
626    entry.retain_partial_path(stage.partial.to_string_lossy().into_owned());
627    let mut control = entry.control.subscribe();
628    if *control.borrow() == Control::Cancel {
629        finish_cancel(&entry, None, &mut stage, None).await;
630        return;
631    }
632    let options = NativeRecordingOptions {
633        max_width: entry.params.max_width,
634        max_height: entry.params.max_height,
635        fps: entry.params.fps.map(f64::from),
636    };
637    let started =
638        tokio::time::timeout(Duration::from_secs(20), page.start_recording(options)).await;
639    let native = match started {
640        Ok(Ok(native)) => native,
641        Ok(Err(error)) => {
642            let failure = native_failure(
643                RecordingFailureStage::Start,
644                error,
645                Some(stage.partial.to_string_lossy().into_owned()),
646            );
647            finish_cancel(&entry, None, &mut stage, Some(failure)).await;
648            return;
649        }
650        Err(_) => {
651            let failure = RecordingFailure {
652                stage: RecordingFailureStage::Start,
653                code: ErrorCode::RecordingFailed,
654                message: "native recording start exceeded its deadline; cleanup is uncertain"
655                    .to_owned(),
656                partial_path: Some(stage.partial.to_string_lossy().into_owned()),
657                cleanup_uncertain: Some(true),
658            };
659            finish_cancel(&entry, None, &mut stage, Some(failure)).await;
660            return;
661        }
662    };
663    entry.update(|state| {
664        state.admitted = true;
665    });
666    loop {
667        let command = *control.borrow_and_update();
668        match command {
669            Control::Cancel => {
670                finish_cancel(&entry, Some(&native), &mut stage, None).await;
671                return;
672            }
673            Control::Stop => break,
674            Control::Run => {}
675        }
676        tokio::select! {
677            biased;
678            _ = control.changed() => {},
679            error = native.wait_for_failure() => {
680                let failure = native_failure(RecordingFailureStage::Disconnect, error, Some(stage.partial.to_string_lossy().into_owned()));
681                finish_cancel(&entry, Some(&native), &mut stage, Some(failure)).await;
682                return;
683            },
684        }
685    }
686    let drained = tokio::select! {
687        biased;
688        _ = cancellation(&mut control) => None,
689        result = drain(&native, &mut stage) => Some(result),
690    };
691    match drained {
692        None => {
693            finish_cancel(&entry, Some(&native), &mut stage, None).await;
694            return;
695        }
696        Some(Err(failure)) => {
697            finish_cancel(&entry, Some(&native), &mut stage, Some(failure)).await;
698            return;
699        }
700        Some(Ok(())) => {}
701    }
702    let publisher = entry.clone();
703    let published = tokio::task::spawn_blocking(move || {
704        let attempted = publish(&publisher, &stage);
705        (stage, attempted)
706    })
707    .await;
708    match published {
709        Ok((mut stage, false)) => finish_cancel(&entry, Some(&native), &mut stage, None).await,
710        Err(error) => entry.fail(RecordingFailure {
711            stage: RecordingFailureStage::Publish,
712            code: ErrorCode::FileOutput,
713            message: error.to_string(),
714            partial_path: entry.partial_path(),
715            cleanup_uncertain: None,
716        }),
717        _ => {}
718    }
719}
720
721fn publish(entry: &Entry, stage: &StagedArtifact) -> bool {
722    publish_with(entry, || {
723        stage.publish("video/mp4", Some(ArtifactContainer::Mp4))
724    })
725}
726
727fn publish_with(
728    entry: &Entry,
729    commit: impl FnOnce() -> Result<eoka_protocol::sidecar::Artifact, FileFailure>,
730) -> bool {
731    if !entry.begin_publication() {
732        return false;
733    }
734    let result = commit();
735    entry.finish_publication(result);
736    true
737}
738
739trait RecordingSource {
740    fn stop(&self) -> impl Future<Output = Result<(), RecordingError>> + Send;
741    fn read_chunk(
742        &self,
743    ) -> impl Future<Output = Result<eoka::RecordingChunk, RecordingError>> + Send;
744    fn close(&self) -> impl Future<Output = Result<(), RecordingError>> + Send;
745}
746
747impl RecordingSource for NativeRecording {
748    fn stop(&self) -> impl Future<Output = Result<(), RecordingError>> + Send {
749        NativeRecording::stop(self)
750    }
751    fn read_chunk(
752        &self,
753    ) -> impl Future<Output = Result<eoka::RecordingChunk, RecordingError>> + Send {
754        NativeRecording::read_chunk(self)
755    }
756    fn close(&self) -> impl Future<Output = Result<(), RecordingError>> + Send {
757        NativeRecording::close(self)
758    }
759}
760
761async fn repair_stage(stage: &mut StagedArtifact) -> Result<(), RecordingFailure> {
762    let partial_path = Some(stage.partial.to_string_lossy().into_owned());
763    let file = stage.recording_file().map_err(file_failure)?;
764    let result = tokio::time::timeout(Duration::from_secs(5), async {
765        file.flush().await.map_err(|error| RecordingFailure {
766            stage: RecordingFailureStage::Flush,
767            code: ErrorCode::FileOutput,
768            message: error.to_string(),
769            partial_path: partial_path.clone(),
770            cleanup_uncertain: None,
771        })?;
772        mp4::repair(file).await.map_err(|error| RecordingFailure {
773            stage: RecordingFailureStage::Close,
774            code: ErrorCode::RecordingFailed,
775            message: format!("MP4 timing validation and repair failed: {error}"),
776            partial_path: partial_path.clone(),
777            cleanup_uncertain: None,
778        })
779    })
780    .await;
781    match result {
782        Ok(result) => result,
783        Err(_) => Err(RecordingFailure {
784            stage: RecordingFailureStage::Close,
785            code: ErrorCode::RecordingFailed,
786            message: "MP4 timing validation and repair exceeded its deadline; the retained partial may contain an incomplete timing correction".to_owned(),
787            partial_path,
788            cleanup_uncertain: None,
789        }),
790    }
791}
792
793async fn drain(
794    native: &impl RecordingSource,
795    stage: &mut StagedArtifact,
796) -> Result<(), RecordingFailure> {
797    let partial = || Some(stage.partial.to_string_lossy().into_owned());
798    match tokio::time::timeout(Duration::from_secs(15), native.stop()).await {
799        Ok(result) => {
800            result.map_err(|error| native_failure(RecordingFailureStage::Stop, error, partial()))?
801        }
802        Err(_) => {
803            return Err(RecordingFailure {
804                stage: RecordingFailureStage::Stop,
805                code: ErrorCode::RecordingFailed,
806                message: "native stop exceeded its deadline".to_owned(),
807                partial_path: partial(),
808                cleanup_uncertain: Some(true),
809            })
810        }
811    }
812    loop {
813        let chunk = match tokio::time::timeout(Duration::from_secs(15), native.read_chunk()).await {
814            Ok(result) => result.map_err(|error| {
815                native_failure(
816                    RecordingFailureStage::Read,
817                    error,
818                    Some(stage.partial.to_string_lossy().into_owned()),
819                )
820            })?,
821            Err(_) => {
822                return Err(RecordingFailure {
823                    stage: RecordingFailureStage::Read,
824                    code: ErrorCode::RecordingFailed,
825                    message: "native stream read exceeded its deadline".to_owned(),
826                    partial_path: Some(stage.partial.to_string_lossy().into_owned()),
827                    cleanup_uncertain: None,
828                })
829            }
830        };
831        if chunk.data.len() > 65_536 {
832            return Err(RecordingFailure {
833                stage: RecordingFailureStage::Read,
834                code: ErrorCode::RecordingFailed,
835                message: "native stream exceeded its bounded chunk contract".to_owned(),
836                partial_path: Some(stage.partial.to_string_lossy().into_owned()),
837                cleanup_uncertain: None,
838            });
839        }
840        stage.write(&chunk.data).await.map_err(file_failure)?;
841        if chunk.eof {
842            break;
843        }
844    }
845    native.close().await.map_err(|error| {
846        native_failure(
847            RecordingFailureStage::Close,
848            error,
849            Some(stage.partial.to_string_lossy().into_owned()),
850        )
851    })?;
852    repair_stage(stage).await?;
853    stage.seal().await.map_err(file_failure)
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    fn params(path: String) -> RecordingStartParams {
861        RecordingStartParams {
862            page_id: "target".to_owned(),
863            recording_id: "11111111-1111-4111-8111-111111111111".to_owned(),
864            path,
865            max_width: None,
866            max_height: None,
867            fps: None,
868        }
869    }
870
871    fn indexed_params(index: usize) -> RecordingStartParams {
872        let path = std::env::temp_dir().join(format!("recording-{index}.mp4"));
873        let mut params = params(path.to_string_lossy().into_owned());
874        params.recording_id = format!("{index:08x}-1111-4111-8111-111111111111");
875        params
876    }
877
878    fn parameter_bytes(params: &RecordingStartParams) -> usize {
879        params.recording_id.len() + params.page_id.len() + params.path.len()
880    }
881
882    fn sized_params(index: usize, bytes: usize) -> RecordingStartParams {
883        let mut params = indexed_params(index);
884        let fixed_bytes = params.recording_id.len() + params.path.len();
885        params.page_id = "é".to_owned();
886        params
887            .page_id
888            .push_str(&"x".repeat(bytes - fixed_bytes - params.page_id.len()));
889        assert_eq!(parameter_bytes(&params), bytes);
890        params
891    }
892
893    #[test]
894    fn distinct_id_limit_preserves_recovery_and_conflict_precedence() {
895        let manager = RecordingManager::default();
896        for index in 0..MAX_SESSION_RECORDINGS {
897            manager.admit(indexed_params(index)).unwrap();
898        }
899        let existing = indexed_params(MAX_SESSION_RECORDINGS - 1);
900        let entry = manager.entry(&existing.recording_id).unwrap();
901        manager.admit(existing.clone()).unwrap();
902        assert!(Arc::ptr_eq(
903            &entry,
904            &manager.reserve(existing.clone()).unwrap()
905        ));
906        let mut different = existing.clone();
907        different.fps = Some(30);
908        assert_eq!(
909            manager.admit(different).unwrap_err().code,
910            ErrorCode::RecordingConflict
911        );
912        let rejected = indexed_params(MAX_SESSION_RECORDINGS);
913        let error = manager.admit(rejected.clone()).unwrap_err();
914        assert_eq!(error.code, ErrorCode::ServerBusy);
915        assert!(error.message.contains("1024 distinct IDs"));
916        assert!(error.message.contains("completed, cancelled, and failed"));
917        assert!(error.message.contains("sidecar lifetime"));
918        assert!(error.message.contains("new Browser/session"));
919        let data = error.data.unwrap();
920        assert_eq!(
921            data.recording_id.as_deref(),
922            Some(rejected.recording_id.as_str())
923        );
924        assert_eq!(data.path.as_deref(), Some(rejected.path.as_str()));
925        assert!(data.recording.is_none());
926        assert_eq!(manager.history.lock().entries.len(), MAX_SESSION_RECORDINGS);
927        assert!(manager.entry(&rejected.recording_id).is_err());
928        RecordingManager::default().admit(rejected).unwrap();
929    }
930
931    #[test]
932    fn retained_parameter_budget_has_exact_utf8_boundary_without_consuming_rejections() {
933        let manager = RecordingManager::default();
934        let last = indexed_params(1);
935        let last_bytes = parameter_bytes(&last);
936        let first = sized_params(0, MAX_SESSION_RECORDING_PARAMETER_BYTES - last_bytes);
937        manager.admit(first).unwrap();
938        let before = manager.history.lock().retained_parameter_bytes;
939        let oversized = sized_params(2, MAX_SESSION_RECORDING_PARAMETER_BYTES + 1);
940        assert_eq!(
941            manager.admit(oversized).unwrap_err().code,
942            ErrorCode::ServerBusy
943        );
944        let over_budget = sized_params(3, last_bytes + 1);
945        let error = manager.admit(over_budget).unwrap_err();
946        assert_eq!(error.code, ErrorCode::ServerBusy);
947        assert!(error
948            .message
949            .contains("16777216 retained start-parameter bytes"));
950        assert_eq!(manager.history.lock().retained_parameter_bytes, before);
951        assert_eq!(manager.history.lock().entries.len(), 1);
952        assert!(manager.entry(&indexed_params(2).recording_id).is_err());
953        assert!(manager.entry(&indexed_params(3).recording_id).is_err());
954        manager.admit(last.clone()).unwrap();
955        assert_eq!(
956            manager.history.lock().retained_parameter_bytes,
957            MAX_SESSION_RECORDING_PARAMETER_BYTES
958        );
959        manager.admit(last.clone()).unwrap();
960        let mut different = last.clone();
961        different.page_id.push('x');
962        assert_eq!(
963            manager.admit(different).unwrap_err().code,
964            ErrorCode::RecordingConflict
965        );
966        assert_eq!(
967            manager.admit(indexed_params(4)).unwrap_err().code,
968            ErrorCode::ServerBusy
969        );
970        assert_eq!(manager.history.lock().entries.len(), 2);
971        assert_eq!(
972            manager.history.lock().retained_parameter_bytes,
973            MAX_SESSION_RECORDING_PARAMETER_BYTES
974        );
975    }
976
977    #[test]
978    fn invalid_parameters_do_not_consume_history_capacity() {
979        let manager = RecordingManager::default();
980        for index in 0..MAX_SESSION_RECORDINGS - 1 {
981            manager.admit(indexed_params(index)).unwrap();
982        }
983        let bytes = manager.history.lock().retained_parameter_bytes;
984        let mut invalid = indexed_params(MAX_SESSION_RECORDINGS);
985        invalid.fps = Some(0);
986        assert_eq!(
987            manager.admit(invalid).unwrap_err().code,
988            ErrorCode::InvalidParams
989        );
990        assert_eq!(manager.history.lock().retained_parameter_bytes, bytes);
991        assert_eq!(
992            manager.history.lock().entries.len(),
993            MAX_SESSION_RECORDINGS - 1
994        );
995        manager
996            .admit(indexed_params(MAX_SESSION_RECORDINGS))
997            .unwrap();
998        assert_eq!(
999            manager
1000                .admit(indexed_params(MAX_SESSION_RECORDINGS + 1))
1001                .unwrap_err()
1002                .code,
1003            ErrorCode::ServerBusy
1004        );
1005    }
1006
1007    #[tokio::test]
1008    async fn terminal_outcomes_retain_slots_and_remain_recoverable_at_capacity() {
1009        let manager = RecordingManager::default();
1010        let root = tempfile::tempdir().unwrap();
1011        let mut completed = indexed_params(0);
1012        completed.path = root
1013            .path()
1014            .join("completed.mp4")
1015            .to_string_lossy()
1016            .into_owned();
1017        let entry = manager.reserve(completed.clone()).unwrap();
1018        entry.update(|state| {
1019            state.started = true;
1020            state.admitted = true;
1021        });
1022        let mut stage = StagedArtifact::prepare(&completed.path).await.unwrap();
1023        stage.write(mp4::VALID_MP4).await.unwrap();
1024        repair_stage(&mut stage).await.unwrap();
1025        stage.seal().await.unwrap();
1026        assert!(publish(&entry, &stage));
1027        let cancelled = indexed_params(1);
1028        manager.admit(cancelled.clone()).unwrap();
1029        manager.cancel(&cancelled.recording_id).await.unwrap();
1030        for index in 3..MAX_SESSION_RECORDINGS {
1031            manager.admit(indexed_params(index)).unwrap();
1032        }
1033        let failed = indexed_params(2);
1034        assert_eq!(
1035            manager
1036                .start(failed.clone(), Err(ServerError::invalid_page("missing")))
1037                .await
1038                .unwrap_err()
1039                .code,
1040            ErrorCode::InvalidPage
1041        );
1042        assert_eq!(
1043            manager
1044                .admit(indexed_params(MAX_SESSION_RECORDINGS))
1045                .unwrap_err()
1046                .code,
1047            ErrorCode::ServerBusy
1048        );
1049        let bytes = manager.history.lock().retained_parameter_bytes;
1050        manager.shutdown().await.unwrap();
1051        assert_eq!(manager.history.lock().entries.len(), MAX_SESSION_RECORDINGS);
1052        assert_eq!(manager.history.lock().retained_parameter_bytes, bytes);
1053        for (params, state) in [
1054            (completed, RecordingState::Completed),
1055            (cancelled, RecordingState::Cancelled),
1056            (failed, RecordingState::Failed),
1057        ] {
1058            manager.admit(params.clone()).unwrap();
1059            assert_eq!(
1060                manager.get(&params.recording_id).await.unwrap().state,
1061                state
1062            );
1063            assert_eq!(
1064                manager
1065                    .start(
1066                        params.clone(),
1067                        Err(ServerError::internal("must not restart"))
1068                    )
1069                    .await
1070                    .unwrap()
1071                    .state,
1072                state
1073            );
1074            let mut different = params;
1075            different.fps = Some(30);
1076            assert_eq!(
1077                manager.admit(different).unwrap_err().code,
1078                ErrorCode::RecordingConflict
1079            );
1080        }
1081    }
1082
1083    #[tokio::test]
1084    async fn capacity_rejection_does_not_reserve_launch_or_stage() {
1085        let manager = RecordingManager::default();
1086        for index in 0..MAX_SESSION_RECORDINGS {
1087            manager.admit(indexed_params(index)).unwrap();
1088        }
1089        let root = tempfile::tempdir().unwrap();
1090        let mut rejected = indexed_params(MAX_SESSION_RECORDINGS);
1091        rejected.path = root
1092            .path()
1093            .join("rejected.mp4")
1094            .to_string_lossy()
1095            .into_owned();
1096        let bytes = manager.history.lock().retained_parameter_bytes;
1097        assert_eq!(
1098            manager
1099                .start(
1100                    rejected.clone(),
1101                    Err(ServerError::internal("worker must not run"))
1102                )
1103                .await
1104                .unwrap_err()
1105                .code,
1106            ErrorCode::ServerBusy
1107        );
1108        tokio::task::yield_now().await;
1109        assert_eq!(
1110            manager.get(&rejected.recording_id).await.unwrap_err().code,
1111            ErrorCode::RecordingNotFound
1112        );
1113        assert_eq!(manager.history.lock().entries.len(), MAX_SESSION_RECORDINGS);
1114        assert_eq!(manager.history.lock().retained_parameter_bytes, bytes);
1115        assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
1116        assert_eq!(
1117            manager
1118                .entry(&indexed_params(0).recording_id)
1119                .unwrap()
1120                .snapshot()
1121                .state,
1122            RecordingState::Running
1123        );
1124    }
1125
1126    #[test]
1127    fn concurrent_new_ids_cannot_exceed_count_or_parameter_byte_limits() {
1128        for byte_limited in [false, true] {
1129            let manager = RecordingManager::default();
1130            let contender_bytes = 16 * 1024;
1131            if byte_limited {
1132                manager
1133                    .admit(sized_params(
1134                        0,
1135                        MAX_SESSION_RECORDING_PARAMETER_BYTES - 8 * contender_bytes,
1136                    ))
1137                    .unwrap();
1138            } else {
1139                for index in 0..MAX_SESSION_RECORDINGS - 8 {
1140                    manager.admit(indexed_params(index)).unwrap();
1141                }
1142            }
1143            let barrier = Arc::new(std::sync::Barrier::new(16));
1144            let results = std::thread::scope(|scope| {
1145                let mut jobs = Vec::new();
1146                for index in MAX_SESSION_RECORDINGS..MAX_SESSION_RECORDINGS + 16 {
1147                    let manager = manager.clone();
1148                    let barrier = barrier.clone();
1149                    jobs.push(scope.spawn(move || {
1150                        let params = if byte_limited {
1151                            sized_params(index, contender_bytes)
1152                        } else {
1153                            indexed_params(index)
1154                        };
1155                        barrier.wait();
1156                        manager.admit(params)
1157                    }));
1158                }
1159                jobs.into_iter()
1160                    .map(|job| job.join().unwrap())
1161                    .collect::<Vec<_>>()
1162            });
1163            assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 8);
1164            for error in results.into_iter().filter_map(Result::err) {
1165                assert_eq!(error.code, ErrorCode::ServerBusy);
1166            }
1167            let history = manager.history.lock();
1168            if byte_limited {
1169                assert_eq!(history.entries.len(), 9);
1170                assert_eq!(
1171                    history.retained_parameter_bytes,
1172                    MAX_SESSION_RECORDING_PARAMETER_BYTES
1173                );
1174            } else {
1175                assert_eq!(history.entries.len(), MAX_SESSION_RECORDINGS);
1176                assert!(history.retained_parameter_bytes < MAX_SESSION_RECORDING_PARAMETER_BYTES);
1177            }
1178        }
1179    }
1180
1181    #[tokio::test]
1182    async fn failed_admission_is_recoverable_and_same_id_never_restarts() {
1183        let manager = RecordingManager::default();
1184        let root = tempfile::tempdir().unwrap();
1185        let params = params(
1186            root.path()
1187                .join("capture.mp4")
1188                .to_string_lossy()
1189                .into_owned(),
1190        );
1191        let error = manager
1192            .start(params.clone(), Err(ServerError::invalid_page("missing")))
1193            .await
1194            .unwrap_err();
1195        assert_eq!(
1196            error.data.unwrap().recording_id.as_deref(),
1197            Some(params.recording_id.as_str())
1198        );
1199        let first = manager.get(&params.recording_id).await.unwrap();
1200        assert_eq!(first.state, RecordingState::Failed);
1201        assert_eq!(
1202            manager
1203                .start(params.clone(), Err(ServerError::internal("different")))
1204                .await
1205                .unwrap()
1206                .state,
1207            RecordingState::Failed
1208        );
1209        let recovered = manager.get(&params.recording_id).await.unwrap();
1210        assert_eq!(
1211            recovered.failure.unwrap().message,
1212            first.failure.unwrap().message
1213        );
1214        let mut different = params.clone();
1215        different.fps = Some(30);
1216        assert_eq!(
1217            manager
1218                .start(different, Err(ServerError::internal("unused")))
1219                .await
1220                .unwrap_err()
1221                .code,
1222            ErrorCode::RecordingConflict
1223        );
1224        assert_eq!(
1225            manager.stop(&params.recording_id).await.unwrap().state,
1226            RecordingState::Failed
1227        );
1228        assert_eq!(
1229            manager.cancel(&params.recording_id).await.unwrap().state,
1230            RecordingState::Failed
1231        );
1232    }
1233
1234    #[tokio::test]
1235    async fn shared_waiters_observe_one_terminal_result_and_abort_does_not_cancel() {
1236        let manager = RecordingManager::default();
1237        let entry = manager
1238            .reserve(params("/tmp/video.mp4".to_owned()))
1239            .unwrap();
1240        entry.update(|state| state.admitted = true);
1241        let id = entry.params.recording_id.clone();
1242        let first_manager = manager.clone();
1243        let first_id = id.clone();
1244        let first = tokio::spawn(async move { first_manager.stop(&first_id).await.unwrap() });
1245        tokio::task::yield_now().await;
1246        first.abort();
1247        assert!(!entry.state.lock().cancelled);
1248        assert_eq!(*entry.control.borrow(), Control::Stop);
1249        let other_manager = manager.clone();
1250        let second = tokio::spawn(async move { other_manager.stop(&id).await.unwrap() });
1251        entry.fail(RecordingFailure {
1252            stage: RecordingFailureStage::Read,
1253            code: ErrorCode::RecordingFailed,
1254            message: "stream disconnected".to_owned(),
1255            partial_path: Some("/tmp/.partial".to_owned()),
1256            cleanup_uncertain: Some(true),
1257        });
1258        let result = second.await.unwrap();
1259        assert_eq!(result.state, RecordingState::Failed);
1260        assert_eq!(
1261            result.failure.unwrap().partial_path.as_deref(),
1262            Some("/tmp/.partial")
1263        );
1264    }
1265
1266    #[tokio::test]
1267    async fn cancellation_and_publication_are_linearized_without_success_named_partials() {
1268        for cancel_first in [false, true] {
1269            let manager = RecordingManager::default();
1270            let root = tempfile::tempdir().unwrap();
1271            let output = root.path().join("video.mp4");
1272            let entry = manager
1273                .reserve(params(output.to_string_lossy().into_owned()))
1274                .unwrap();
1275            entry.update(|state| {
1276                state.started = true;
1277                state.admitted = true;
1278            });
1279            let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1280                .await
1281                .unwrap();
1282            stage.write(mp4::VALID_MP4).await.unwrap();
1283            repair_stage(&mut stage).await.unwrap();
1284            stage.seal().await.unwrap();
1285            if cancel_first {
1286                entry.request(true);
1287                entry.request(false);
1288                assert_eq!(*entry.control.borrow(), Control::Cancel);
1289                assert!(!publish(&entry, &stage));
1290                finish_cancel(&entry, None, &mut stage, None).await;
1291                assert_eq!(entry.snapshot().state, RecordingState::Cancelled);
1292                assert!(!output.exists());
1293                assert!(stage.partial.exists());
1294            } else {
1295                assert!(publish(&entry, &stage));
1296                assert_eq!(
1297                    manager
1298                        .cancel(&entry.params.recording_id)
1299                        .await
1300                        .unwrap()
1301                        .state,
1302                    RecordingState::Completed
1303                );
1304                assert_eq!(std::fs::read(&output).unwrap(), mp4::VALID_MP4);
1305            }
1306        }
1307    }
1308
1309    struct FakeSource {
1310        chunks: Mutex<std::collections::VecDeque<Result<eoka::RecordingChunk, RecordingError>>>,
1311        close_fails: bool,
1312    }
1313
1314    impl RecordingSource for FakeSource {
1315        async fn stop(&self) -> Result<(), RecordingError> {
1316            Ok(())
1317        }
1318        async fn read_chunk(&self) -> Result<eoka::RecordingChunk, RecordingError> {
1319            self.chunks.lock().pop_front().unwrap()
1320        }
1321        async fn close(&self) -> Result<(), RecordingError> {
1322            if self.close_fails {
1323                Err(RecordingError::Core(eoka::Error::cdp_msg(
1324                    "IO.close failed",
1325                )))
1326            } else {
1327                Ok(())
1328            }
1329        }
1330    }
1331
1332    struct ClosingSource {
1333        source: FakeSource,
1334        entered: tokio::sync::Notify,
1335    }
1336
1337    impl RecordingSource for ClosingSource {
1338        async fn stop(&self) -> Result<(), RecordingError> {
1339            self.source.stop().await
1340        }
1341
1342        async fn read_chunk(&self) -> Result<eoka::RecordingChunk, RecordingError> {
1343            self.source.read_chunk().await
1344        }
1345
1346        async fn close(&self) -> Result<(), RecordingError> {
1347            self.entered.notify_one();
1348            std::future::pending().await
1349        }
1350    }
1351
1352    #[tokio::test]
1353    async fn cancellation_during_finalization_retains_unpublished_partial() {
1354        let manager = RecordingManager::default();
1355        let root = tempfile::tempdir().unwrap();
1356        let output = root.path().join("cancelled.mp4");
1357        let entry = manager
1358            .reserve(params(output.to_string_lossy().into_owned()))
1359            .unwrap();
1360        entry.update(|state| {
1361            state.started = true;
1362            state.admitted = true;
1363        });
1364        let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1365            .await
1366            .unwrap();
1367        entry.retain_partial_path(stage.partial.to_string_lossy().into_owned());
1368        let mut control = entry.control.subscribe();
1369        let source = ClosingSource {
1370            source: FakeSource {
1371                chunks: Mutex::new(std::collections::VecDeque::from([Ok(
1372                    eoka::RecordingChunk {
1373                        data: mp4::REPAIRABLE_MP4.to_vec(),
1374                        eof: true,
1375                    },
1376                )])),
1377                close_fails: false,
1378            },
1379            entered: tokio::sync::Notify::new(),
1380        };
1381        let draining = async {
1382            tokio::select! {
1383                biased;
1384                _ = cancellation(&mut control) => None,
1385                result = drain(&source, &mut stage) => Some(result),
1386            }
1387        };
1388        let cancelling = async {
1389            source.entered.notified().await;
1390            entry.request(true);
1391        };
1392        let (result, ()) = tokio::join!(draining, cancelling);
1393        assert!(result.is_none());
1394        finish_cancel(&entry, None, &mut stage, None).await;
1395        assert!(!publish(&entry, &stage));
1396        let result = entry.snapshot();
1397        assert_eq!(result.state, RecordingState::Cancelled);
1398        assert!(result.artifact.is_none());
1399        let failure = result.failure.unwrap();
1400        assert_eq!(
1401            failure.partial_path.as_deref(),
1402            Some(stage.partial.to_str().unwrap())
1403        );
1404        assert_eq!(failure.cleanup_uncertain, None);
1405        assert!(!output.exists());
1406        assert_eq!(std::fs::read(&stage.partial).unwrap(), mp4::REPAIRABLE_MP4);
1407    }
1408
1409    #[tokio::test]
1410    async fn valid_and_repairable_eof_payloads_finalize_before_publication() {
1411        for data in [mp4::VALID_MP4, mp4::REPAIRABLE_MP4] {
1412            let manager = RecordingManager::default();
1413            let root = tempfile::tempdir().unwrap();
1414            let output = root.path().join("capture.mp4");
1415            let entry = manager
1416                .reserve(params(output.to_string_lossy().into_owned()))
1417                .unwrap();
1418            entry.update(|state| {
1419                state.started = true;
1420                state.admitted = true;
1421            });
1422            let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1423                .await
1424                .unwrap();
1425            let source = FakeSource {
1426                chunks: Mutex::new(std::collections::VecDeque::from([
1427                    Ok(eoka::RecordingChunk {
1428                        data: data[..data.len() - 16].to_vec(),
1429                        eof: false,
1430                    }),
1431                    Ok(eoka::RecordingChunk {
1432                        data: data[data.len() - 16..].to_vec(),
1433                        eof: true,
1434                    }),
1435                ])),
1436                close_fails: false,
1437            };
1438            drain(&source, &mut stage).await.unwrap();
1439            assert!(!output.exists());
1440            assert!(publish(&entry, &stage));
1441            let result = entry.snapshot();
1442            assert_eq!(result.state, RecordingState::Completed);
1443            assert_eq!(result.artifact.unwrap().bytes, data.len() as u64);
1444            assert!(!stage.partial.exists());
1445            assert_eq!(std::fs::read(output).unwrap(), mp4::VALID_MP4);
1446        }
1447    }
1448
1449    #[tokio::test]
1450    async fn invalid_native_containers_fail_without_sealing_or_publication() {
1451        for data in [
1452            &[][..],
1453            &b"not an MP4 container"[..],
1454            &mp4::VALID_MP4[..mp4::VALID_MP4.len() - 1],
1455        ] {
1456            let manager = RecordingManager::default();
1457            let root = tempfile::tempdir().unwrap();
1458            let output = root.path().join("invalid.mp4");
1459            let entry = manager
1460                .reserve(params(output.to_string_lossy().into_owned()))
1461                .unwrap();
1462            entry.update(|state| {
1463                state.started = true;
1464                state.admitted = true;
1465            });
1466            let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1467                .await
1468                .unwrap();
1469            let source = FakeSource {
1470                chunks: Mutex::new(std::collections::VecDeque::from([Ok(
1471                    eoka::RecordingChunk {
1472                        data: data.to_vec(),
1473                        eof: true,
1474                    },
1475                )])),
1476                close_fails: false,
1477            };
1478            let failure = drain(&source, &mut stage).await.unwrap_err();
1479            assert_eq!(failure.stage, RecordingFailureStage::Close);
1480            assert_eq!(failure.code, ErrorCode::RecordingFailed);
1481            assert!(stage
1482                .publish("video/mp4", Some(ArtifactContainer::Mp4))
1483                .is_err());
1484            finish_cancel(&entry, None, &mut stage, Some(failure)).await;
1485            let result = entry.snapshot();
1486            assert_eq!(result.state, RecordingState::Failed);
1487            assert!(result.artifact.is_none());
1488            let failure = result.failure.unwrap();
1489            assert_eq!(
1490                failure.partial_path.as_deref(),
1491                Some(stage.partial.to_str().unwrap())
1492            );
1493            assert_eq!(failure.cleanup_uncertain, None);
1494            assert!(!output.exists());
1495            assert_eq!(std::fs::read(&stage.partial).unwrap(), data);
1496            assert!(!publish(&entry, &stage));
1497        }
1498    }
1499
1500    #[tokio::test]
1501    async fn stream_and_close_faults_preserve_stage_without_success_named_output() {
1502        for close_fails in [false, true] {
1503            let root = tempfile::tempdir().unwrap();
1504            let output = root.path().join("capture.mp4");
1505            let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1506                .await
1507                .unwrap();
1508            let mut chunks = std::collections::VecDeque::new();
1509            chunks.push_back(Ok(eoka::RecordingChunk {
1510                data: b"partial".to_vec(),
1511                eof: close_fails,
1512            }));
1513            if !close_fails {
1514                chunks.push_back(Err(RecordingError::Core(eoka::Error::cdp_msg(
1515                    "IO.read disconnected",
1516                ))));
1517            }
1518            let source = FakeSource {
1519                chunks: Mutex::new(chunks),
1520                close_fails,
1521            };
1522            let failure = drain(&source, &mut stage).await.unwrap_err();
1523            assert_eq!(
1524                failure.stage,
1525                if close_fails {
1526                    RecordingFailureStage::Close
1527                } else {
1528                    RecordingFailureStage::Read
1529                }
1530            );
1531            assert_eq!(
1532                failure.partial_path.as_deref(),
1533                Some(stage.partial.to_str().unwrap())
1534            );
1535            stage.abandon().await.unwrap();
1536            assert!(!output.exists());
1537            assert_eq!(std::fs::read(&stage.partial).unwrap(), b"partial");
1538        }
1539    }
1540
1541    #[tokio::test]
1542    async fn oversized_native_chunks_are_rejected_before_writing() {
1543        let root = tempfile::tempdir().unwrap();
1544        let output = root.path().join("capture.mp4");
1545        let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1546            .await
1547            .unwrap();
1548        let source = FakeSource {
1549            chunks: Mutex::new(std::collections::VecDeque::from([Ok(
1550                eoka::RecordingChunk {
1551                    data: vec![0; 65_537],
1552                    eof: true,
1553                },
1554            )])),
1555            close_fails: false,
1556        };
1557        assert_eq!(
1558            drain(&source, &mut stage).await.unwrap_err().stage,
1559            RecordingFailureStage::Read
1560        );
1561        stage.abandon().await.unwrap();
1562        assert_eq!(std::fs::metadata(&stage.partial).unwrap().len(), 0);
1563        assert!(!output.exists());
1564    }
1565
1566    #[tokio::test]
1567    async fn reserved_admission_can_be_cancelled_without_starting_a_recorder() {
1568        let manager = RecordingManager::default();
1569        let params = indexed_params(0);
1570        manager.admit(params.clone()).unwrap();
1571        assert_eq!(
1572            manager.cancel(&params.recording_id).await.unwrap().state,
1573            RecordingState::Cancelled
1574        );
1575        assert_eq!(
1576            manager
1577                .start(params, Err(ServerError::internal("must not dispatch")))
1578                .await
1579                .unwrap()
1580                .state,
1581            RecordingState::Cancelled
1582        );
1583    }
1584
1585    #[tokio::test]
1586    async fn status_and_cancel_remain_responsive_while_stop_waits() {
1587        let manager = RecordingManager::default();
1588        let entry = manager
1589            .reserve(params("/tmp/controls.mp4".to_owned()))
1590            .unwrap();
1591        entry.update(|state| {
1592            state.started = true;
1593            state.admitted = true;
1594        });
1595        let mut command = entry.control.subscribe();
1596        let stop_manager = manager.clone();
1597        let id = entry.params.recording_id.clone();
1598        let stopping = tokio::spawn(async move { stop_manager.stop(&id).await.unwrap() });
1599        command.changed().await.unwrap();
1600        let status = tokio::time::timeout(
1601            Duration::from_millis(100),
1602            manager.get(&entry.params.recording_id),
1603        )
1604        .await
1605        .unwrap()
1606        .unwrap();
1607        assert_eq!(status.state, RecordingState::Finalizing);
1608        let cancel_manager = manager.clone();
1609        let id = entry.params.recording_id.clone();
1610        let cancelling = tokio::spawn(async move { cancel_manager.cancel(&id).await.unwrap() });
1611        command.changed().await.unwrap();
1612        assert_eq!(*command.borrow(), Control::Cancel);
1613        assert!(!stopping.is_finished());
1614        entry.update(|state| {
1615            state.record.state = RecordingState::Cancelled;
1616        });
1617        assert_eq!(stopping.await.unwrap().state, RecordingState::Cancelled);
1618        let result = cancelling.await.unwrap();
1619        assert_eq!(result.state, RecordingState::Cancelled);
1620        assert!(result.artifact.is_none());
1621    }
1622
1623    #[tokio::test]
1624    async fn shutdown_expiry_keeps_partial_evidence_and_cannot_later_turn_into_success() {
1625        let manager = RecordingManager::default();
1626        let entry = manager
1627            .reserve(params("/tmp/timed-out.mp4".to_owned()))
1628            .unwrap();
1629        entry.update(|state| {
1630            state.started = true;
1631            state.admitted = true;
1632            state.partial_path = Some("/tmp/.eoka-capture.partial".to_owned());
1633        });
1634        let error = manager.shutdown_timeout_error().unwrap();
1635        let failure = error.data.unwrap().recording.unwrap().failure.unwrap();
1636        assert_eq!(failure.stage, RecordingFailureStage::Cancel);
1637        assert_eq!(failure.cleanup_uncertain, Some(true));
1638        assert_eq!(
1639            failure.partial_path.as_deref(),
1640            Some("/tmp/.eoka-capture.partial")
1641        );
1642        let root = tempfile::tempdir().unwrap();
1643        let mut stage = StagedArtifact::prepare(root.path().join("late.mp4").to_str().unwrap())
1644            .await
1645            .unwrap();
1646        finish_cancel(&entry, None, &mut stage, None).await;
1647        assert_eq!(entry.snapshot().state, RecordingState::Failed);
1648        assert!(entry.snapshot().artifact.is_none());
1649    }
1650
1651    #[tokio::test]
1652    async fn blocked_commit_does_not_block_status_cancel_or_shutdown_arbitration() {
1653        for expire in [false, true] {
1654            let manager = RecordingManager::default();
1655            let root = tempfile::tempdir().unwrap();
1656            let output = root.path().join("commit.mp4");
1657            let entry = manager
1658                .reserve(params(output.to_string_lossy().into_owned()))
1659                .unwrap();
1660            entry.update(|state| {
1661                state.started = true;
1662                state.admitted = true;
1663            });
1664            entry.request(false);
1665            let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1666                .await
1667                .unwrap();
1668            stage.write(mp4::VALID_MP4).await.unwrap();
1669            repair_stage(&mut stage).await.unwrap();
1670            stage.seal().await.unwrap();
1671            entry.retain_partial_path(stage.partial.to_string_lossy().into_owned());
1672            let (ready, waiting) = std::sync::mpsc::channel();
1673            let (release, blocked) = std::sync::mpsc::channel();
1674            let publisher = entry.clone();
1675            let publishing = tokio::task::spawn_blocking(move || {
1676                publish_with(&publisher, || {
1677                    ready.send(()).unwrap();
1678                    blocked.recv_timeout(Duration::from_secs(2)).unwrap();
1679                    stage.publish("video/mp4", Some(ArtifactContainer::Mp4))
1680                })
1681            });
1682            waiting.recv_timeout(Duration::from_secs(1)).unwrap();
1683            let started = std::time::Instant::now();
1684            assert_eq!(
1685                manager.get(&entry.params.recording_id).await.unwrap().state,
1686                RecordingState::Finalizing
1687            );
1688            entry.request(true);
1689            manager.request_shutdown();
1690            assert!(started.elapsed() < Duration::from_millis(100));
1691            assert!(!terminal(entry.snapshot().state));
1692            if expire {
1693                let started = std::time::Instant::now();
1694                let error = manager.shutdown_timeout_error().unwrap();
1695                assert!(started.elapsed() < Duration::from_millis(100));
1696                let failure = error.data.unwrap().recording.unwrap().failure.unwrap();
1697                assert_eq!(failure.stage, RecordingFailureStage::Publish);
1698                assert_eq!(failure.cleanup_uncertain, Some(true));
1699                assert!(failure.message.contains("may still complete"));
1700            }
1701            release.send(()).unwrap();
1702            assert!(publishing.await.unwrap());
1703            let result = manager.cancel(&entry.params.recording_id).await.unwrap();
1704            assert_eq!(
1705                result.state,
1706                if expire {
1707                    RecordingState::Failed
1708                } else {
1709                    RecordingState::Completed
1710                }
1711            );
1712            assert_eq!(std::fs::read(&output).unwrap(), mp4::VALID_MP4);
1713            if expire {
1714                let failure = result.failure.unwrap();
1715                assert!(failure.partial_path.is_none());
1716                assert!(failure.message.contains("publication later completed"));
1717                assert!(result.artifact.is_none());
1718            }
1719        }
1720    }
1721
1722    #[tokio::test]
1723    async fn late_staging_after_terminal_shutdown_keeps_path_without_changing_outcome() {
1724        let manager = RecordingManager::default();
1725        let root = tempfile::tempdir().unwrap();
1726        let output = root.path().join("late.mp4");
1727        let entry = manager
1728            .reserve(params(output.to_string_lossy().into_owned()))
1729            .unwrap();
1730        entry.update(|state| {
1731            state.started = true;
1732            state.admitted = true;
1733        });
1734        manager.shutdown_timeout_error().unwrap();
1735        assert!(entry.snapshot().failure.unwrap().partial_path.is_none());
1736        let mut stage = StagedArtifact::prepare(output.to_str().unwrap())
1737            .await
1738            .unwrap();
1739        entry.retain_partial_path(stage.partial.to_string_lossy().into_owned());
1740        finish_cancel(&entry, None, &mut stage, None).await;
1741        let record = manager.get(&entry.params.recording_id).await.unwrap();
1742        assert_eq!(record.state, RecordingState::Failed);
1743        assert_eq!(
1744            record.failure.unwrap().partial_path.as_deref(),
1745            Some(stage.partial.to_str().unwrap())
1746        );
1747        assert!(stage.partial.exists());
1748        assert!(!output.exists());
1749    }
1750}