Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/desktop-gpui/src/main_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,11 @@ impl MainWindow {
this.incomplete_recording = incomplete.into_iter().next();
if this.incomplete_recording.is_none() {
this.recovery_error = None;
} else if std::env::var("CAP_GPUI_AUTO_RECOVER").is_ok_and(|value| value == "1") {
// The crash harness presses Recover, since unprivileged
// synthetic clicks are dropped (as for every
// `CAP_GPUI_AUTO_*` hook).
this.process_incomplete_recording(true, window, cx);
}
cx.notify();
window.refresh();
Expand Down
156 changes: 155 additions & 1 deletion apps/desktop-gpui/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1411,7 +1411,7 @@ pub(crate) fn ensure_finalization_storage(project_path: &std::path::Path) -> any
}

pub fn recover_instant_recording(project_path: &std::path::Path) -> anyhow::Result<PathBuf> {
use cap_project::{InstantRecordingMeta, RecordingMeta, RecordingMetaInner};
use cap_project::{InstantRecordingMeta, RecordingMeta, RecordingMetaInner, UploadMeta};

let mut meta = RecordingMeta::load_for_project(project_path)
.map_err(|error| anyhow!("loading recording metadata: {error}"))?;
Expand Down Expand Up @@ -1450,6 +1450,9 @@ pub fn recover_instant_recording(project_path: &std::path::Path) -> anyhow::Resu
.map(|decoder| decoder.rate())
})
.transpose()?;
if matches!(meta.upload, Some(UploadMeta::SegmentUpload { .. })) {
seal_recovered_instant_tracks(project_path);
}
meta.inner = RecordingMetaInner::Instant(InstantRecordingMeta::Complete {
fps: fps.round() as u32,
sample_rate,
Expand All @@ -1459,6 +1462,46 @@ pub fn recover_instant_recording(project_path: &std::path::Path) -> anyhow::Resu
Ok(project_path.to_path_buf())
}

/// Seals the tracks of a recovered Instant recording so the upload queue can
/// send what the crash left: the crash leaves each manifest unfinished, and the
/// upload resume only accepts finished ones. Recovery stands if sealing fails;
/// the recording is saved locally either way, and its upload then fails as it
/// did before sealing existed.
///
/// Recovery can rescue a final fragment the manifest never listed, so the
/// recovered `output.mp4` may hold more than the sealed tracks. Whenever
/// sealing sets any media aside, or fails, the local recording is kept after
/// its upload verifies, so it is never the only complete copy that gets
/// deleted.
pub(crate) fn seal_recovered_instant_tracks(project_path: &std::path::Path) {
use cap_enc_ffmpeg::segmented_stream::SegmentMediaType;
let mut omitted = false;
for (track, media_type) in [
("display", SegmentMediaType::Video),
("audio", SegmentMediaType::Audio),
] {
let directory = project_path.join("content").join(track);
if !directory.join("manifest.json").exists() {
continue;
}
match cap_recording::upload_resume::seal_interrupted_track(&directory, media_type) {
Ok(segments) => tracing::info!(track, segments, "Sealed a recovered Instant track"),
Err(error) => {
omitted = true;
tracing::warn!(track, %error, "Could not seal a recovered Instant track")
}
}
omitted |= std::fs::read_dir(&directory).map_or(true, |entries| {
entries
.flatten()
.any(|entry| entry.file_name().to_string_lossy().ends_with(".unsealed"))
});
}
if omitted && let Err(error) = crate::upload::queue::record_preserve_local(project_path) {
tracing::warn!(%error, "Could not keep a recovered recording after its upload");
}
}

/// The first segment's display track, which is what
/// `handle_recording_finish` hands `create_screenshot`
/// (`apps/desktop/src-tauri/src/recording.rs:3415-3429`).
Expand Down Expand Up @@ -2810,6 +2853,117 @@ mod tests {
#[cfg(target_os = "linux")]
use std::time::Duration;

fn copy_tree(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let target = to.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_tree(&entry.path(), &target);
} else {
std::fs::copy(entry.path(), target).unwrap();
}
}
}

/// A real Instant recording copied mid-capture, which is what a crash
/// leaves, then recovered: Recover saves it locally and seals its track,
/// so the upload resume accepts every segment the crash saved.
#[test]
fn recovering_a_crashed_instant_recording_seals_it_for_upload() {
use cap_enc_ffmpeg::segmented_stream::{
SegmentMediaType, SegmentedVideoEncoder, SegmentedVideoEncoderConfig,
};
use cap_project::{
InstantRecordingMeta, RecordingMeta, RecordingMetaInner, S3UploadMeta, UploadMeta,
VideoUploadInfo,
};
ffmpeg::init().unwrap();
let live = tempfile::tempdir().unwrap();
let display = live.path().join("display");
let info = cap_media_info::VideoInfo {
pixel_format: cap_media_info::Pixel::NV12,
width: 64,
height: 64,
time_base: ffmpeg::Rational(1, 1_000_000),
frame_rate: ffmpeg::Rational(30, 1),
};
let mut encoder = SegmentedVideoEncoder::init(
display.clone(),
info,
SegmentedVideoEncoderConfig {
segment_duration: std::time::Duration::from_secs(1),
..Default::default()
},
)
.unwrap();
for index in 0..300u64 {
let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::NV12, 64, 64);
for plane in 0..frame.planes() {
frame.data_mut(plane).fill((index % 200) as u8);
}
encoder
.queue_frame(
frame,
std::time::Duration::from_nanos(index * 1_000_000_000 / 30),
)
.unwrap();
}
let root = tempfile::tempdir().unwrap();
let project = root.path().join("crashed.cap");
copy_tree(&display, &project.join("content/display"));
encoder.finish().unwrap();

let manifest: Value = serde_json::from_slice(
&std::fs::read(project.join("content/display/manifest.json")).unwrap(),
)
.unwrap();
assert_eq!(manifest["is_complete"], Value::Bool(false));
let finished = manifest["segments"]
.as_array()
.unwrap()
.iter()
.filter(|segment| segment["is_complete"] == Value::Bool(true))
.count();
assert!(finished >= 1, "the crash saved no finished segment");
assert!(
cap_recording::upload_resume::collect_segment_events(&project, false).is_err(),
"a crashed recording must not be resumable before Recover"
);
let video = VideoUploadInfo {
id: "crashedvideo01".into(),
link: "https://cap.test/s/crashedvideo01".into(),
config: S3UploadMeta {
id: "crashedvideo01".into(),
},
};
RecordingMeta {
platform: None,
project_path: project.clone(),
pretty_name: "Crashed".into(),
sharing: None,
inner: RecordingMetaInner::Instant(InstantRecordingMeta::InProgress {
recording: true,
}),
upload: Some(UploadMeta::SegmentUpload {
video_id: video.id.clone(),
pre_created_video: video,
recording_dir: project.clone(),
}),
}
.save_for_project()
.unwrap();

recover_instant_recording(&project).unwrap();
assert!(project.join("content/output.mp4").is_file());
let events = cap_recording::upload_resume::collect_segment_events(&project, false).unwrap();
let segments = events
.iter()
.filter(|event| !event.is_init && event.media_type == SegmentMediaType::Video)
.count();
assert_eq!(segments, finished);
}

#[cfg(target_os = "linux")]
#[test]
fn instant_screen_requested_camera_cannot_bypass_processing_preparation() {
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop-gpui/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2746,6 +2746,58 @@ mod tests {
}
}

/// The finished segment manifest this app uploads, checked against the
/// files the web server's own parser is tested with
/// (`apps/web/__tests__/unit/desktop-segments-manifest-contract.test.ts`),
/// so neither side can change the format without the other noticing. Run
/// with `CAP_UPDATE_CONTRACT_FIXTURES=1` to rewrite the files after an
/// intended change.
#[test]
fn finished_segment_manifests_match_the_server_contract_fixtures() {
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../web/__tests__/fixtures/desktop-segments");
for (name, audio) in [("with-audio", 2), ("video-only", 0)] {
let mut manifest = SegmentUploadManifest::default();
manifest.record(&segment_event(0, 0.0, true, SegmentMediaType::Video));
for (index, duration) in [(1, 2.0), (2, 2.0), (3, 1.25)] {
manifest.record(&segment_event(
index,
duration,
false,
SegmentMediaType::Video,
));
}
if audio > 0 {
manifest.record(&segment_event(0, 0.0, true, SegmentMediaType::Audio));
for index in 1..=audio {
manifest.record(&segment_event(index, 3.0, false, SegmentMediaType::Audio));
}
}
manifest.is_complete = true;
let uploaded = serde_json::to_value(&manifest).unwrap();
let path = fixtures.join(format!("finished-manifest-{name}.json"));
if std::env::var_os("CAP_UPDATE_CONTRACT_FIXTURES").is_some() {
std::fs::create_dir_all(&fixtures).unwrap();
let mut json = Vec::new();
let formatter = serde_json::ser::PrettyFormatter::with_indent(b"\t");
let mut serializer = serde_json::Serializer::with_formatter(&mut json, formatter);
serde::Serialize::serialize(&uploaded, &mut serializer).unwrap();
json.push(b'\n');
std::fs::write(&path, json).unwrap();
}
let fixture: serde_json::Value = serde_json::from_slice(
&std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())),
)
.unwrap();
assert_eq!(
fixture,
uploaded,
"{} no longer matches what the app uploads",
path.display()
);
}
}

#[test]
fn segment_manifest_requires_init_and_orders_video_and_audio() {
let mut manifest = SegmentUploadManifest::default();
Expand Down
Loading
Loading