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(¶ms.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(¶ms.recording_id).await?,
476 "recording.stop" => manager.stop(¶ms.recording_id).await?,
477 "recording.cancel" => manager.cancel(¶ms.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(¶ms), 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(¶ms.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(¶ms.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(¶ms.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(¶ms.recording_id).await.unwrap().state,
1226 RecordingState::Failed
1227 );
1228 assert_eq!(
1229 manager.cancel(¶ms.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(¶ms.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}